Skip to content

Tools

Register an HTTP endpoint of yours as a tool an agent can call during a chat, with a signed request, a JSON schema for its arguments and a JSON answer.

A tool lets an agent reach live data or trigger an action in your systems: a CRM lookup, a stock level, a ticket creation. You register an HTTPS endpoint of your own with a description and a JSON schema for its arguments. When an agent that has the tool linked decides it needs it, Subsidia calls your endpoint, signs the request, reads your JSON answer and gives it to the model, which then writes its reply. Your server never talks to the model, and the model never talks to your server: Subsidia is in the middle, which is where masking, limits and logging happen.

Tools are the integration point for everything that is not a document. For documents, use Knowledge sources; for facts the agent should simply know, use memory.

  1. User message
  2. Agent decides to call a tool
  3. Subsidia signs and POSTs to your URL
  4. Your endpoint returns JSON
  5. Agent writes the answer
  6. toolCalls in the response
The agent asks, Subsidia calls. Your endpoint sees a signed HTTP request and returns JSON; the result goes back into the same turn.

How a tool is described

FieldRequiredWhat it is
nameYesThe function name the model sees. Use snake_case letters, digits, _ and -. Any other character run is replaced by _ before it reaches the model ("CRM lookup!" becomes "CRM_lookup"), so two names that sanitise to the same string collide. The raw name is what your endpoint receives in X-PulseLabs-Tool.
descriptionYesWhat the tool does and when to call it. This is the only guidance the model has. Say what comes back and name the situations ("whenever the user asks about a specific customer").
parametersYesA JSON Schema (type: "object", properties, required) of the arguments. Describe every property in plain words; the model fills them from the conversation.
urlYesThe endpoint Subsidia calls. Use HTTPS. See What Subsidia sends.
agentIdsNoOn creation, the agents to link the tool to right away. You can also link later.
isActiveUpdate onlyAn inactive tool is never offered to a model, without being deleted or unlinked.

What Subsidia sends

Tools registered through the public API are called with POST. The arguments the model chose, as a JSON object, are the request body.

PartValue
MethodPOST
URLThe registered url. Placeholders written as {name} in the URL are replaced by the URL-encoded value of the argument of the same name, and that argument is then not repeated in the body (https://api.example.com/clients/{clientId}/status).
BodyThe arguments as JSON, for example {"company":"Atelier Moreau","include_invoices":true}. If every argument was consumed by URL placeholders, or the model passed none, there is no body and no Content-Type: treat a missing body as {}.
Content-Typeapplication/json, when there is a body.
X-PulseLabs-ToolThe tool name as registered.
X-PulseLabs-Signaturesha256= followed by the hex HMAC-SHA256 of the string {"tool":"<name>","arguments":<arguments>} (compact JSON, exactly as JSON.stringify writes it), keyed with the tool secret. See Verify the signature.
User-AgentPulseLabs-Tools/1.0
Timeout15 seconds for the whole call. There is no retry.
RedirectsNot followed: a 3xx is a failure.

What your endpoint must return

  • Status 2xx means success. Anything else (4xx, 5xx, a timeout, a network error) is a failure: the model is told the call failed, with your response text as the reason, and answers with what it has. The turn itself does not fail.
  • Body: JSON. It is parsed and handed to the model as structured data. A body that is not JSON is handed over as plain text, which also works for short answers.
  • Keep it small and relevant. Only the first 16 KB of the response is read; beyond that it is cut (and a cut JSON document is no longer valid JSON). Return what the model needs to answer, not a database row: names, statuses, amounts, dates, in readable keys. The model reads your keys, so "status": "active" works better than "s": 1.
  • Return errors the model can use. 404 with {"error": "No customer named Moreau. Did you mean Atelier Moreau?"} lets the agent ask the user the right question.
  • Be quick. The user is waiting on the answer. Fifteen seconds is a ceiling, not a target.

Security

  • Verify the signature on every request. It proves the call comes from your Subsidia workspace and that the arguments were not altered. Compare in constant time. The signature has no timestamp, so it does not by itself stop a captured request from being replayed: for writes, make operations idempotent.
  • The secret is shown once. Registration returns secret (toolsec_ followed by 64 hex characters) in the creation response, and never again; reads omit it. There is no rotation endpoint: to change a secret, create a new tool with the same URL, link it, and delete the old one.
  • Use HTTPS with a certificate from a public authority. Arguments contain what the user said.
  • Masking depends on where the tool lives. A tool on your own network (localhost, private ranges such as 10.x, 172.16-31.x, 192.168.x, link-local addresses, .local hosts) receives the arguments as the model chose them, with real names and values, because internal automations break otherwise. A tool on the public internet receives the same personal-data shielding as any external model call: personal values in the arguments are replaced by consistent placeholders, and your response is restored before the agent reads it. Design public endpoints to accept placeholders without failing (Synapse).
  • Least privilege. Give the tool a read-only credential on your side, scoped to what the agent needs. Do not put API keys of your own in the parameters schema or the description: both are shown to the model.
  • Linking is granting. A tool linked to an agent can be triggered by anyone who can chat with that agent. Link sensitive tools only to the agents that need them.

Verify the signature

The signature covers the logical call, {"tool": <name>, "arguments": <body>}, serialised as compact JSON. To verify it, rebuild that string from the X-PulseLabs-Tool header and the parsed body, HMAC it with your secret, and compare with X-PulseLabs-Signature. Rebuild with the same compact serialisation that JSON.stringify produces (no spaces, keys in the original order, non-ASCII characters left as they are). Parsing and re-serialising keeps key order in both Node.js and Python 3.7+.

Worked example: a CRM lookup

Goal: an agent named Claire can answer "is Atelier Moreau up to date on payments?" from your CRM. Three steps: write the endpoint, register it, link it.

1. The endpoint

Your server
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.SUBSIDIA_TOOL_SECRET! // toolsec_... returned once at registration
const app = express()
app.use(express.json())
function signatureIsValid(req: express.Request): boolean {
const tool = req.header('X-PulseLabs-Tool') ?? ''
const received = req.header('X-PulseLabs-Signature') ?? ''
// No body at all means no arguments: express.json() leaves {} in that case.
const payload = JSON.stringify({ tool, arguments: req.body ?? {} })
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(payload).digest('hex')
const a = Buffer.from(received)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Stand-in for your real CRM client.
async function findCustomer(company: string) {
const db: Record<string, unknown> = {
'atelier moreau': { company: 'Atelier Moreau', status: 'active', owner: 'S. Bernard', openInvoices: [{ number: 'Q3-114', amountEur: 12480, dueDate: '2026-10-15' }] },
}
return db[company.trim().toLowerCase()] ?? null
}
app.post('/crm-lookup', async (req, res) => {
if (!signatureIsValid(req)) return res.status(401).json({ error: 'bad signature' })
const { company, include_invoices } = req.body ?? {}
if (typeof company !== 'string' || !company.trim()) {
return res.status(400).json({ error: 'company is required' })
}
const customer: any = await findCustomer(company)
if (!customer) return res.status(404).json({ error: 'No customer named ' + company })
// Return only what the agent needs to answer.
const { openInvoices, ...account } = customer
res.json(include_invoices ? { ...account, openInvoices } : account)
})
app.listen(3000)

2. Register it

Call POST /v1/tools (reference below) once. Keep the secret from the response in your secret manager and give it to the endpoint above as SUBSIDIA_TOOL_SECRET. Passing agentIds links the tool at the same time.

curl
curl https://api.subsidia.protypa.fr/v1/tools \
-H "Authorization: Bearer $SUBSIDIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Returns the account status, the owner and, on request, the open invoices. Call it whenever the user asks about a specific customer.",
"parameters": {
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." },
"include_invoices": { "type": "boolean", "description": "Also return the open invoices. Default false." }
},
"required": ["company"]
},
"url": "https://tools.example.com/crm-lookup",
"agentIds": ["'"$AGENT_ID"'"]
}'

3. Ask a question

Chat with the agent normally. Tools are on by default; the call shows up in toolCalls (and as tool_call / tool_result events when streaming).

const res = await fetch('https://api.subsidia.protypa.fr/v1/agents/' + agentId + '/chat', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.SUBSIDIA_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'Is Atelier Moreau up to date on payments? Show the open invoices.' }),
})
const turn = await res.json()
console.log(turn.content)
console.log(turn.toolCalls)
// [{ toolName: 'crm_lookup', arguments: { company: 'Atelier Moreau', include_invoices: true },
// result: { company: 'Atelier Moreau', status: 'active', ... }, success: true, durationMs: 143 }]

How calls are bounded

Within one turn the model can ask for tools up to three rounds (a round may contain several calls), then it must answer. Each call has a 15-second timeout and a 16 KB response cap. Tool calls are part of the turn: they are metered with it and recorded in the audit journal of the workspace.

Endpoints

All tool routes need the agents scope on restricted keys (/v1/tools* and /v1/agents/* share it). Create, update, delete and link are refused for readonly keys. Tools are scoped to the workspace of the key.

POST/v1/tools

Register a tool.

API key (Bearer or x-api-key)agents

Creates the tool, generates its signing secret and optionally links it to agents. The secret is returned once, in this response. Registration does not call your URL, so a typo is only discovered at the first use: test the endpoint yourself first.

Request body

  • namestringrequired
    Function name shown to the model. Prefer snake_case.
  • descriptionstringrequired
    What the tool does and when to call it.
  • parametersobjectrequired
    JSON Schema of the arguments (type: "object", properties, required).
  • urlstringrequired
    HTTPS endpoint Subsidia will POST to.
  • agentIdsstring[]
    Ids of the agents of your workspace to link the tool to.

Request examples

curl https://api.subsidia.protypa.fr/v1/tools \
-H "Authorization: Bearer $SUBSIDIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Call it whenever the user asks about a specific customer.",
"parameters": {"type":"object","properties":{"company":{"type":"string"}},"required":["company"]},
"url": "https://tools.example.com/crm-lookup"
}'

Responses

The tool, and its signing secret.

Example response
{
"tool": {
"id": "cm2kd2m7r000fqz0fa4t6y9ch",
"workspaceId": "cm1w0b0000000qz0fworksp01",
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.",
"parameters": {
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." }
},
"required": ["company"]
},
"url": "https://tools.example.com/crm-lookup",
"method": "POST",
"paramLocations": null,
"isActive": true,
"createdAt": "2026-10-09T09:30:12.447Z",
"updatedAt": "2026-10-09T09:30:12.447Z"
},
"secret": "toolsec_9f2c4b7a1e6d3085c7b1a94f2e0d68b35a7c19e4d2f0836b5c8a1e7d94f203ab"
}

Errors

  • 400name, description, parameters or url is missing.
  • 403read_only_keyThe key carries the readonly scope.
  • 403scope_deniedA restricted key without the agents scope.

GET/v1/tools

List the tools of the workspace.

API key (Bearer or x-api-key)agents

Newest first. Each tool lists the agents it is linked to (id and name). The secret is never included.

Request examples

curl https://api.subsidia.protypa.fr/v1/tools -H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

An object with a tools array.

Example response
{ "tools": [
{
"id": "cm2kd2m7r000fqz0fa4t6y9ch",
"workspaceId": "cm1w0b0000000qz0fworksp01",
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.",
"parameters": {
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." }
},
"required": ["company"]
},
"url": "https://tools.example.com/crm-lookup",
"method": "POST",
"paramLocations": null,
"isActive": true,
"createdAt": "2026-10-09T09:30:12.447Z",
"updatedAt": "2026-10-09T09:30:12.447Z",
"agents": [{ "id": "cm2k8x1ab0001qz0f7h3d9t4e", "name": "Claire" }]
}
] }

GET/v1/tools/:id

Get one tool.

API key (Bearer or x-api-key)agents

Returns the tool with its linked agents. The secret is not returned.

Path parameters

  • idstringrequired
    The tool id.

Request examples

curl
curl https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

The tool.

Example response
{ "tool": {
"id": "cm2kd2m7r000fqz0fa4t6y9ch",
"workspaceId": "cm1w0b0000000qz0fworksp01",
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.",
"parameters": {
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." }
},
"required": ["company"]
},
"url": "https://tools.example.com/crm-lookup",
"method": "POST",
"paramLocations": null,
"isActive": true,
"createdAt": "2026-10-09T09:30:12.447Z",
"updatedAt": "2026-10-09T09:30:12.447Z",
"agents": []
} }

Errors

  • 404No tool with this id in the workspace.

PUT/v1/tools/:id

Update a tool.

API key (Bearer or x-api-key)agents

Partial update: send only what changes. Use isActive: false to take a tool out of service without losing its links. The secret and the linked agents are not changed here.

Path parameters

  • idstringrequired
    The tool id.

Request body

  • namestring
    New function name.
  • descriptionstring
    New description.
  • parametersobject
    New JSON Schema.
  • urlstring
    New endpoint.
  • isActiveboolean
    Enable or disable the tool.

Request examples

curl
curl -X PUT https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID \
-H "Authorization: Bearer $SUBSIDIA_API_KEY" -H "Content-Type: application/json" \
-d '{"isActive": false}'

Responses

The updated tool.

Example response
{ "tool": {
"id": "cm2kd2m7r000fqz0fa4t6y9ch",
"workspaceId": "cm1w0b0000000qz0fworksp01",
"name": "crm_lookup",
"description": "Look up a customer in the CRM by company name. Returns the account status, the owner and the open invoices. Call it whenever the user asks about a specific customer.",
"parameters": {
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." }
},
"required": ["company"]
},
"url": "https://tools.example.com/crm-lookup",
"method": "POST",
"paramLocations": null,
"isActive": false,
"createdAt": "2026-10-09T09:30:12.447Z",
"updatedAt": "2026-10-09T09:30:12.447Z"
} }

Errors

  • 404No tool with this id in the workspace.

DELETE/v1/tools/:id

Delete a tool.

API key (Bearer or x-api-key)agents

Deletes the tool and its links to agents. Agents stop offering it immediately. This also invalidates its secret.

Path parameters

  • idstringrequired
    The tool id.

Request examples

curl
curl -X DELETE https://api.subsidia.protypa.fr/v1/tools/$TOOL_ID -H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

Deleted. No body.

Errors

  • 404No tool with this id in the workspace.

POST/v1/agents/:id/tools/:toolId

Link a tool to an agent.

API key (Bearer or x-api-key)agents

Makes the tool available to this agent in its next turn. Linking twice is harmless. Returns the agent with the list of its tools (id and name).

Path parameters

  • idstringrequired
    The agent id.
  • toolIdstringrequired
    The tool id.

Request examples

curl -X POST https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/tools/$TOOL_ID \
-H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

The agent, with a tools array.

Example response
{ "agent": { "id": "cm2k8x1ab0001qz0f7h3d9t4e", "name": "Claire", "tools": [{ "id": "cm2kd2m7r000fqz0fa4t6y9ch", "name": "crm_lookup" }] } }

Errors

  • 404The agent or the tool does not exist in the workspace.

DELETE/v1/agents/:id/tools/:toolId

Unlink a tool from an agent.

API key (Bearer or x-api-key)agents

The tool is kept and stays linked to its other agents. The agent stops offering it from its next turn.

Path parameters

  • idstringrequired
    The agent id.
  • toolIdstringrequired
    The tool id.

Request examples

curl
curl -X DELETE https://api.subsidia.protypa.fr/v1/agents/$AGENT_ID/tools/$TOOL_ID \
-H "Authorization: Bearer $SUBSIDIA_API_KEY"

Responses

Unlinked. No body.

Errors

  • 404No agent with this id in the workspace.

Schema reference

parameters is a standard JSON Schema object, passed to the model unchanged. Supported in practice: type (string, number, integer, boolean, array, object), properties, required, enum, items, description. Always set "type": "object" at the top. Example:

parameters
{
"type": "object",
"properties": {
"company": { "type": "string", "description": "Company name as the user wrote it." },
"include_invoices": { "type": "boolean", "description": "Also return the open invoices. Default false." }
},
"required": ["company"]
}

Related