An agent is a configuration you create once — instructions, a model, and the capabilities it is allowed to use — and then call from your own product with a single API key.
The difference from calling the services directly is who does the orchestration. Without
an agent, extracting a date from a photo of an ID card is your job: call /v1/ocr/scan,
read the fields, decide whether the answer is there, then call the chat API to phrase it.
With an agent you send the photo and the question, and it works out for itself that it
needs OCR first.
Build one in the dashboard, or through the endpoints below.
Authorization
An agent is reached with an ordinary API key that carries the agents scope. Add it
on the API keys page — you can change a key’s scopes at any time, without issuing a new
one.
The key needs nothing else. What the agent may reach for is decided by its own capability list, not by the key: an agent granted OCR can read documents even though the key never mentions OCR. One grant, in the place you chose the capability.
Restricting a key to one agent
A key’s agents scope decides whether it may reach Agent Builder at all. Which agents it may call is a separate setting on the same key, and by default it is all
of them — so a key issued for one integration can drive every agent you own.
Give each integration its own key and restrict it:
// PATCH /v1/auth/keys/{keyId}
{ "agentIds": ["8f2a1c4e-…"] } // only this agent
{ "agentIds": null } // lift the restriction Anything outside a key’s list answers 404, not 403 — a 403 would confirm the
agent exists and tell the holder of a narrow key what else you run. GET /v1/agents filters for the same reason: a restricted key sees only the agents it may call.
An empty array means the key may call no agents, which is not the same as null. Set it deliberately or not at all.
Invoking an agent
/v1/agents/{id}/invoke API key Starts the agent and returns immediately. The answer is collected separately, by polling or by subscribing to the stream.
Body
message string | What you are asking the agent. Required unless you send a file. |
file file | Multipart only. Repeat the field to send several. Images, PDFs, Office documents and audio. |
files array | JSON only. Each item is { fileName, mimeType } plus either `data` (base64) or `url`. |
sessionId uuid | Continue an earlier conversation. Returned by every invocation as `sessionId`. |
Query
wait boolean | Block for up to 25 seconds and return the finished invocation if it lands in time. Falls back to the 202 below. |
curl -X POST https://ai-api.vanguardinitiative.com/v1/agents/$AGENT_ID/invoke \
-H "Authorization: Bearer $API_KEY" \
-F 'message=When does this document expire?' \
-F 'file=@id-card.jpg'const form = new FormData()
form.append('message', 'When does this document expire?')
form.append('file', file)
const { invocationId } = await fetch(
`https://ai-api.vanguardinitiative.com/v1/agents/${agentId}/invoke`,
{ method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: form }
).then((r) => r.json())import requests
started = requests.post(
f"https://ai-api.vanguardinitiative.com/v1/agents/{agent_id}/invoke",
headers={"Authorization": f"Bearer {api_key}"},
data={"message": "When does this document expire?"},
files={"file": open("id-card.jpg", "rb")},
).json(){
"status": "success",
"invocationId": "8f2a1c4e-...",
"sessionId": "b71d9e02-...",
"state": "queued",
"stream": "/v1/agents/invocations/8f2a1c4e-.../stream",
"poll": "/v1/agents/invocations/8f2a1c4e-..."
}Reading the result
/v1/agents/invocations/{invocationId} API key /v1/agents/invocations/{invocationId}/stream API key Poll the first for the finished answer, or subscribe to the second for output as it is produced. The stream replays from the beginning when you connect, so a dropped connection costs you nothing: reconnect and you get the whole answer, not just the part that came after.
curl https://ai-api.vanguardinitiative.com/v1/agents/invocations/$INVOCATION_ID \
-H "Authorization: Bearer $API_KEY"const events = new EventSource(
`https://ai-api.vanguardinitiative.com/v1/agents/invocations/${invocationId}/stream`
)
events.onmessage = (e) => {
const event = JSON.parse(e.data)
if (event.type === 'text') process.stdout.write(event.delta)
if (event.type === 'tool') console.log('\n↳', event.name, event.status)
if (event.type === 'done') events.close()
}import time
while True:
inv = requests.get(
f"https://ai-api.vanguardinitiative.com/v1/agents/invocations/{started['invocationId']}",
headers={"Authorization": f"Bearer {api_key}"},
).json()["invocation"]
if inv["status"] in ("success", "error"):
print(inv["output"] or inv["error"])
break
time.sleep(1){
"status": "success",
"invocation": {
"id": "8f2a1c4e-...",
"sessionId": "b71d9e02-...",
"status": "success",
"output": "This is a Lao national ID card issued to ... It expires on 12 April 2029.",
"steps": [{ "tool": "ocr", "status": "done" }],
"credits": 4,
"latencyMs": 6210,
"error": null
}
}steps is the list of capabilities the agent actually used, in order. It is worth
logging: it is how you see what your agent is doing and what it is spending.
Cancelling a run
/v1/agents/invocations/{invocationId}/cancel API key Stops a run in flight. Returns { "status": "success", "stopped": true }, or stopped: false when there was nothing still running — a finished invocation is not an
error to cancel.
Whatever the agent had already produced is kept, and anything it already spent stays spent: the model tokens were generated and the tools it called did their work. Nothing further is charged.
Stream events
Each frame is one JSON object on a data: line.
type | Fields | Meaning |
|---|---|---|
text | delta | A fragment of the answer. Append them. |
tool | name, status | A capability started, finished, or failed. |
file | id, fileName, mimeType | The agent produced a file — generated audio, an image, a document. |
done | output, credits, steps | The final answer and what it cost. |
error | code, message | The run failed. Nothing was charged for work that did not happen. |
Continuing a conversation
Every invocation returns a sessionId. Send it back and the agent remembers the earlier
turns; leave it out and each call is independent.
{ "message": "and in Lao?", "sessionId": "b71d9e02-..." } Sending files
Multipart is the simplest route — repeat the file field for several. With a JSON body,
each entry in files carries either base64 data or a url we fetch on your behalf.
A URL is fetched before the agent starts, so a bad link is a 400 you can act on
rather than a run that fails halfway through. Private and internal addresses are refused.
Character, skills and memory
An agent is more than one prompt. Three fields, and each answers a different question.
Personality — who it is
personality is composed ahead of the instructions and never merged into them.
Keeping them apart is not tidiness: instructions get edited whenever the job
changes, which is often, while character is set once and left alone. In one field,
every instruction tweak risks rewriting the agent’s personality by accident.
If you are moving from OpenClaw or Hermes, this is where SOUL.md goes, and AGENTS.md goes in systemPrompt.
Skills — what it knows how to do
/v1/agents/{id}/skills API key /v1/agents/{id}/skills API key /v1/agents/{id}/skills/{skillId} API key /v1/agents/{id}/skills/{skillId} API key A skill is a written procedure the agent looks up when it needs one. Its prompt carries only the titles and one-line descriptions — so an agent can hold a hundred procedures for about the cost of a hundred lines, and pays for the body of one only when it decides that one is relevant.
POST accepts a whole SKILL.md in markdown and reads the front matter out of
it, so a skill written for OpenClaw, Hermes or eve moves across as a paste rather
than a rewrite. Explicit name and description fields win over the front matter
when you send them. Names are slugged, and posting the same name again replaces
that skill rather than failing — which is what makes re-importing a directory work.
---
name: expense-claims
description: how to file an expense claim
---
# Filing an expense claim
1. … Loading a skill adds instructions to the turn. It never adds a way to run anything, which is why skills are safe to accept from anyone and need no sandbox.
Memory — who it is talking to
/v1/agents/{id}/memories?endUserId=… API key /v1/agents/{id}/memories/{memoryId} API key /v1/agents/{id}/memories?endUserId=… API key Pass endUserId on invoke — your own identifier for the person the agent is
talking to — and the agent starts remembering them. Leave it out and it has no
memory beyond the thread itself.
That identifier is what makes memory outlive a session. Without it, the only
stable handle is the conversation, so the agent would begin every new conversation
knowing nothing; with it, what it learns accumulates across every thread that
person ever opens. It is opaque to us and scoped to your agent, so your user-42 is never anyone else’s.
{ "message": "what did we decide last time?", "endUserId": "user-42" } Memory is written after a conversation, by a pass over what was said — not by the agent deciding mid-sentence to save something. Models given a “remember this” tool use it constantly and on the wrong things; reading the whole exchange once it is over produces far less, and what it produces is what mattered. Sensitive details — card numbers, ID numbers, passwords — are skipped even when volunteered. The pass is not charged: it runs after your request has already returned, and a charge you had no chance to decline would be indefensible.
Everything remembered is readable and erasable through the endpoints above. DELETE with endUserId and no memory id forgets that person completely.
Managing agents
/v1/agents API key /v1/agents API key /v1/agents/{id} API key /v1/agents/{id} API key /v1/agents/{id} API key Body (create / update)
name * string | What you will recognise it by. |
model * string | A model id from GET /v1/chat/models. |
systemPrompt string | The agent's standing instructions. |
capabilities string[] | Ids from GET /v1/agents/capabilities. Anything not listed is never offered to the model. |
projectId uuid | A project whose documents the agent may answer from. |
maxSteps integer | How many tools it may chain in one answer. 1-8, default 5. |
PATCH updates only the fields you send. Setting status to "disabled" stops the
agent answering without deleting it, so the id your code names stays valid.
/v1/agents/{id}/invocations API key The recent invocations for one agent, newest first.
Capabilities
/v1/agents/capabilities API key Everything an agent can be granted, with its price. kind: "service" prices are the floor — speech is billed per minute and synthesis per 1,000 characters, so a long
input costs more than the figure shown.
{
"status": "success",
"capabilities": [
{ "id": "ocr", "label": "OCR", "kind": "service", "credits": 1, "available": true },
{ "id": "stt", "label": "Speech to Text", "kind": "service", "credits": 5, "available": true },
{ "id": "tts", "label": "Text to Speech", "kind": "service", "credits": 2, "available": true },
{ "id": "docproc", "label": "Document Processing", "kind": "service", "credits": 2, "available": true },
{ "id": "web_search", "kind": "tool", "credits": 2, "available": true },
{ "id": "fetch_url", "kind": "tool", "credits": 1, "available": true },
{ "id": "generate_image", "kind": "tool", "credits": 10, "available": true }
]
}An unavailable capability is one this deployment has no key or bucket for. It is listed rather than hidden so an agent that was using it can still show you why it stopped.
Errors
| Code | Meaning |
|---|---|
SCOPE_FORBIDDEN | The key does not carry the agents scope. |
AGENT_DISABLED | The agent exists but is switched off. |
INVALID_CAPABILITY | A capability id that does not exist. The message lists the valid ones. |
INVALID_MODEL | A model id that is not in GET /v1/chat/models. |
PAYLOAD_TOO_LARGE | A file above the per-file limit. |
FILE_FETCH_FAILED | A url in files could not be fetched, or pointed somewhere not allowed. |
QUOTA_EXCEEDED | Not enough credits. Top up and retry. |
See Errors for the shared envelope.