Skip to content

Build an agent that combines knowledge and tools

At the end of this tutorial the agent support answers a question from the handbook and, in the same turn, creates a ticket when the handbook offers no solution. You have put the tools in one toolset, taught the agent in which order to use them, and seen when a knowledge graph adds something.

Building blocks Agents, Knowledge: index and graph. Tools: toolsets. Insight: traces
You need a key with the capabilities agents, knowledge and tooling; for the trace in the check, trace storage must be on for your organisation (Read a trace)
Builds on Give an agent knowledge: the search tool knowledge__handbook, INDEX_ID. Let an agent carry out an action: the connector api__create_ticket

An agent with one search tool and an agent with one action are each simple. Together they raise the question this tutorial answers: who decides when the agent searches and when it acts? You do, in three places: the description of each tool, the agent's instruction and the limit on the number of tool rounds.

1. Put the tools in a toolset

curl -s -X POST "$CORE_URL/toolsets" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"name": "support",
         "description": "What the support assistant needs: the handbook and tickets.",
         "tools": ["knowledge__handbook", "api__create_ticket"]}'
export TOOLSET_ID=tls_4Hw9...

A toolset holds patterns. An exact name is a pattern too; mcp__tickets__* would take in every tool of the MCP server from the previous tutorial. Check what the set holds now:

curl -s "$CORE_URL/toolsets/$TOOLSET_ID/tools" -H "Authorization: Bearer $CORE_KEY"

You use a toolset instead of two single names on the agent because you can extend the set later without a new agent version. That is also the other side of it: an agent version stores the name of the set and not its content. When you want a version to fix exactly which tools run, name them one by one.

A toolset grants nothing. Every tool in it is checked against the key's policy on every call.

2. Store the order in the agent

curl -s -X POST "$CORE_URL/agents/$AGENT_ID/versions" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"model": "<a model id>",
         "instructions": "You are the support assistant of Noordwind. Answer briefly.\n\n1. For every question, search the handbook first and refer to the passage with [n].\n2. If the handbook solves the question, do not create a ticket.\n3. If the handbook does not solve the question, or the customer asks for a ticket, create one. Put in the description what the customer reported and what the handbook says about it.\n4. Name the ticket number and say what the customer can expect.",
         "temperature": 0.2,
         "max_tool_rounds": 4,
         "tools": [{"type": "toolset", "name": "support"}]}'

The instruction describes a way of working: search first, then decide, then act. That keeps the order in one place, and a trace shows you which step a turn skipped.

max_tool_rounds limits how often the model may call tools in one turn. This task needs two rounds: searching and creating a ticket. Four leaves room for a second search and keeps a turn from searching on and on. A turn that reaches the limit ends with status: incomplete.

3. Test the way without a ticket

curl -s -X POST "$CORE_URL/responses" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"agent": {"id": "support"},
         "input": "I threw the box away. Can I still send my order back?"}'

The handbook says a return must be in its original packaging. The answer should therefore hold one mcp_call, for knowledge__handbook, and no ticket.

4. Test the way with a ticket

curl -s -X POST "$CORE_URL/responses" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"agent": {"id": "support"},
         "input": "My wind meter stopped measuring after six weeks. I want someone to look at this."}'

The handbook names the return period of 30 days and says nothing about a defect after that period. The customer also asks for help from a person. The answer should hold two tool calls.

Check that it works

The answer to step 4 (abridged):

{
  "id": "resp_0KQ2mR...",
  "status": "completed",
  "output": [
    { "type": "mcp_call", "name": "knowledge__handbook", "server_label": "knowledge",
      "status": "completed" },
    { "type": "mcp_call", "name": "api__create_ticket", "server_label": "api",
      "arguments": "{\"subject\": \"Wind meter defective after six weeks\", \"description\": \"...\"}",
      "status": "completed" },
    { "type": "message", "role": "assistant",
      "content": [{ "type": "output_text", "text": "The return period of 30 days has passed [1]. I have created ticket T-1044 ..." }] }
  ]
}

The order in output is the order in which the agent worked. The trace shows per round what happened:

curl -s "$CORE_URL/responses/resp_0KQ2mR.../trace" -H "Authorization: Bearer $CORE_KEY"

Each row is one model call, with the tool calls the model asked for under tool_calls. Read the rows from top to bottom to see in which round the agent searched and in which it created the ticket. When the agent does not search, or creates a ticket too soon, GET /traces/{call_id} shows under document.interpretation which tools the model was offered and what it answered (Read a trace).

When a knowledge graph belongs

For the handbook you need no graph. An index answers "which passage is about this", and that is what a handbook asks for.

A graph earns its place when a question is about a connection that is not in one passage. Noordwind sells products from dozens of suppliers, and warranty runs through the supplier. The question "which supplier does the Wind meter WM-2 come from, and what does the handbook say about warranty?" needs two kinds of knowledge: a fact from a list (who supplies what) and a passage from the handbook. An index over the supplier list finds at most rows that resemble the question. A graph knows the relation itself.

If you have no such list, skip this part. A graph asks for a schema and upkeep, and adds nothing for loose documents.

Create a collection and a graph that reads from it. The template graph/from-tables turns every row of a table into entities and relations, with no model. It reads the columns supplier and product as the types Company and Product with the relation supplies; the schema below uses those names for that reason.

curl -s -X POST "$CORE_URL/knowledge/collections" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"name": "suppliers", "description": "Noordwind supplier list"}'
export SUPPLIERS_ID=col_7Rk4...
curl -s -X POST "$CORE_URL/knowledge/graphs" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"name": "suppliers",
         "description": "Which supplier supplies which product.",
         "visibility": "shared",
         "schema": {
           "entity_types": [
             {"name": "Company", "description": "A supplier of Noordwind."},
             {"name": "Product", "description": "A product Noordwind sells."}],
           "relation_types": [
             {"name": "supplies", "description": "The supplier supplies the product.",
              "from": ["Company"], "to": ["Product"]}]},
         "collection_ids": [{"collection_id": "'"$SUPPLIERS_ID"'"}],
         "ingest_pipeline": {"template": "graph/from-tables"}}'
export GRAPH_ID=kg_5Wn1...

Put the list in. A CSV file with the columns supplier and product:

printf 'supplier,product\nVento BV,Wind meter WM-2\nVento BV,Rain gauge RM-1\nPolaris Instruments,Compass K-7\n' > suppliers.csv

curl -s -X POST "$CORE_URL/knowledge/collections/$SUPPLIERS_ID/sources/upload" \
    -H "Authorization: Bearer $CORE_KEY" \
    -F "file=@suppliers.csv"

GET /knowledge/graphs/$GRAPH_ID/sources shows the source with status: ready and a fact_count greater than zero once the graph is filled.

Let the search tool search both. The template retrieval/hybrid-graph searches an index and a graph and merges passages and facts:

curl -s -X POST "$CORE_URL/knowledge/pipelines/from-template" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"template": "retrieval/hybrid-graph", "name": "handbook-and-suppliers",
         "sources": {"index": "'"$INDEX_ID"'", "graph": "'"$GRAPH_ID"'"}}'

Put the existing search tool on the new pipeline. You read the tool's id (rtl_...) with GET /knowledge/retrieval-tools:

curl -s -X PATCH "$CORE_URL/knowledge/retrieval-tools/rtl_2Vc6..." \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"pipeline": {"pipeline_id": "pip_...", "version": 1},
         "description": "Searches the Noordwind customer service handbook and knows which supplier supplies which product."}'

The tool is still called knowledge__handbook, so the toolset and the agent do not change. Now ask the agent "Which supplier does the Wind meter WM-2 come from, and how does warranty work?". The search tool's result then holds, besides passages, a fact such as Vento BV -- supplies -> Wind meter WM-2.

More on schemas, your own mappings and reading facts out of running text is in Graphs through the API.

If it does not work

You see Cause Fix
404 on POST /responses the toolset support does not exist for your key check the name with GET /toolsets
the same 404, while the toolset exists your key's policy refuses an index, collection or graph the pipeline of a search tool in the set names read the rules about Knowledge indexes, Knowledge collections and Knowledge graphs with GET /key (Knowledge through the API)
the agent gets only one tool the other pattern matches nothing, or your key's policy does not allow that tool GET /toolsets/$TOOLSET_ID/tools, and the rules over Tools in GET /key
status: incomplete the turn reached max_tool_rounds read the trace; raise the limit or sharpen the instruction
the agent creates a ticket without searching the instruction or the ticket tool's description allows it put the order in the instruction, and in the connector's description when it is meant to be used
a source of the graph is failed with no_schema or no_pipeline no_schema: the graph has no schema. no_pipeline: the pipeline the graph pinned was deleted send the schema, or put an existing pipeline in ingest_pipeline, and read again (Read again)
the graph stays empty and GET .../sources shows no sources the graph has no ingest_pipeline; it then reads no source at all set ingest_pipeline with PATCH and read again
fact_count is 0 the columns are named something other than supplier and product rename the columns, or make a pipeline of your own with your own mapping (Choose the pipeline)

Read on

Next tutorial: Set up a new customer organisation.