AgentGraft

Reference

Wire protocol

Everything that crosses the network between your browser, AgentGraft, and your host app.

6 min read

Most integrations never need to look at the wire โ€” the SDK and the widget handle every byte automatically. But the moment something goes wrong (a 401, a missing tool result, a streaming hang) you want to know exactly what's supposed to be on the network. This page is the ground-truth contract.

#The three connections

Three sets of HTTP exchanges happen for any working integration. They're independent โ€” none of them block on each other โ€” and each has its own auth model.

  • **Browser โ†’ AgentGraft**: every chat message the visitor types. Authenticated by the public key + user external id + HMAC headers, signed by the host app server-side.
  • **AgentGraft โ†’ Host app**: every tool call the LLM decides to make. Authenticated by an HMAC signature header keyed on the graft's webhook secret.
  • **Host app โ†’ AgentGraft**: the sync command, exactly when the operator runs it. Authenticated by the graft's sk_live_ API key as a Bearer token.

#Browser โ†’ AgentGraft (chat messages)

The widget POSTs every visitor message to /api/v1/widget/conversations/{id}/send on the AgentGraft API. The response is a Server-Sent Events stream of tokens, tool-call notifications, handoff events, and a final assistant message marker.

http
POST /api/v1/widget/conversations/abc-123/send HTTP/1.1
Host: agentgraft.com
Content-Type: application/json
Accept: text/event-stream
X-AgentGraft-Public-Key: pk_live_a1b2c3...
X-AgentGraft-User-Id: 42
X-AgentGraft-User-HMAC: 9f4d2e3a8b7c...

{"content": "what datasets do I have?"}
Three custom headers identify the visitor; the body is one JSON object.

The HMAC header is computed by the host app server-side (via the SDK's compute_user_hmac helper) using the graft's webhook secret as the key and the user external id as the message. AgentGraft re-derives the HMAC from the same secret on the way in and rejects any request where they don't match. This means the public key alone isn't enough to talk to the API โ€” an attacker would need the webhook secret to forge a valid identity, and the webhook secret never leaves your server.

The widget's face reacts to the conversation. As a visitor types, and again as the reply streams, the widget POSTs the text to /api/v1/widget/affect/ with the same headers. AgentGraft asks TypeSafe's Jev how the face should look and answers with a blend of its expressions; the face eases into it while the words are still arriving. It is always on and needs nothing in your config.

#AgentGraft โ†’ Host app (tool calls)

When an agent decides to call a tool, the runtime POSTs a tool dispatch payload to the host app's webhook URL (whatever you configured at sync time, e.g. https://yourapp.com/agentgraft/webhook/). The Django adapter handles this entire request โ€” you don't write any view code.

http
POST /agentgraft/webhook/ HTTP/1.1
Host: yourapp.com
Content-Type: application/json
X-AgentGraft-Graft: 3f2a9c1e-...
X-AgentGraft-Signature: 9f4d2e3a8b7c...

{
  "tool": "get_dataset",
  "arguments": {"dataset_id": 42},
  "user_external_id": "user:7",
  "is_anonymous": false,
  "private": false,
  "agent": "researcher",
  "request_id": "req_018f3a...",
  "timestamp": 1712345678
}

X-AgentGraft-Signature is the hex HMAC-SHA256 of the raw request body, keyed on the graft's webhook secret. Because timestamp and request_id are inside the signed body, they can't be altered or stripped without breaking the signature. The SDK rejects the request with 401 if the signature doesn't match or the timestamp is more than 5 minutes away from the host's clock (replay protection), so keep the host's clock in sync with NTP.

is_anonymous is true when user_external_id is in the session: namespace the template tag uses for logged-out visitors. The server derives it from the HMAC-verified id, never from the browser.

AgentGraft retries a tool call once on network errors and 502/503/504 responses, resending the exact same body. If your tool writes, use request_id as an idempotency key so a retry of a call that already ran doesn't write twice. Inside a tool, ag.current_request_id() returns it.

private is true when the call came from the widget's incognito chat. AgentGraft does not store that chat. Inside the tool, ag.current_call_is_private() returns the flag so a tool that writes can skip the write. Older servers omit the field; treat that as false.

agent names the agent that made the call, as declared in your config (@ag.agent def researcher(): ... calls arrive as "researcher"). The SDK hands it to your tool-call hooks as ToolCall.agent; older servers omit it.

python
@ag.tool
def create_note(user: User, text: str) -> dict:
    request_id = ag.current_request_id()  # None outside a webhook call
    if request_id is None:
        note = Note.objects.create(owner=user, text=text)
    else:
        # agentgraft_request_id: CharField(unique=True, null=True)
        note, _ = Note.objects.get_or_create(
            agentgraft_request_id=request_id,
            defaults={"owner": user, "text": text},
        )
    return {"id": note.pk}
Deduplicating a write tool on request_id.

The response is the JSON-serialised return value of your tool function, wrapped in {"result": ...}:

http
HTTP/1.1 200 OK
Content-Type: application/json

{"result": {"id": 42, "name": "Q1 sales", "rows": 12000}}

Failures come back as {"error": "<message>"} with a status code the runtime passes to the LLM as the tool result, so the conversation carries on: 400 for bad arguments, 401 for a bad signature or stale timestamp, 403 when your user_loader raises, 404 for an unknown tool, and 500 when the tool itself raises.

#Host app โ†’ AgentGraft (sync)

Run by manage.py agentgraft_sync. POSTs the entire declarative bundle (every tool, agent, and prompt block) to the admin API. Authenticated with the graft's sk_live_ API key as a Bearer token. Server-side this is atomic: either every change lands, or none of them do.

http
POST /api/v1/admin/grafts/018f3a7c-.../sync/ HTTP/1.1
Host: agentgraft.com
Authorization: Bearer sk_live_a1b2c3...
Content-Type: application/json
User-Agent: agentgraft-python/0.0.6 (+https://agentgraft.com)

{
  "tools": [
    {"name": "list_datasets", "description": "...", "parameters_schema": {...}},
    {"name": "get_dataset",   "description": "...", "parameters_schema": {...}}
  ],
  "agents": [
    {"name": "helper", "instructions": "...", "tool_names": ["list_datasets", "get_dataset"], "is_entry_point": true}
  ],
  "graft": {
    "name": "Acme",
    "identity": "You are Acme's in-app assistant.",
    "voice": "Be concise...",
    "policies": "...resolved policy text..."
  },
  "webhook_url": "https://yourapp.com/agentgraft/webhook/"
}

Policies referenced by ag.policy(codename) are resolved client-side before the POST โ€” the SDK fetches each one from the public Policy Shop endpoint and concatenates the bodies into the graft.policies string. The runtime never has to know about codenames.

#Streaming responses

The chat send endpoint streams its response as Server-Sent Events. Each event has a type and a payload. The widget consumes them in order; an LLM agent integrating directly would do the same.

text
data: {"type": "token", "content": "I"}

data: {"type": "token", "content": " found"}

data: {"type": "token", "content": " 3 datasets:"}

data: {"type": "tool_call", "name": "list_datasets", "arguments": {}}

data: {"type": "tool_result", "name": "list_datasets", "result": [...]}

data: {"type": "handoff", "from": "router", "to": "data_helper"}

data: {"type": "done", "message_id": "msg_018f3a..."}
Real streams are interleaved โ€” tokens flow while tool calls and handoffs land.

#Identity flow at a glance

Putting it all together for a single chat turn: the host app server renders the widget tag, which signs the user's id with the webhook secret and embeds the signature in the <script> element's data attributes. The widget boots in the browser, includes those headers on every API call to AgentGraft. The runtime verifies the HMAC, runs the agent loop, and POSTs any tool calls back to the host app's webhook signed with the same secret. The host app re-verifies, runs the tool, returns the result. The runtime streams the final answer back to the widget. None of the user's PII ever lives on AgentGraft โ€” only the opaque external id.