Conversing with an agent

An agent speaks three standard wire formats directly, so any client built for OpenAI Chat Completions, Anthropic Messages, or AG-UI works against it with nothing but a base URL and a key: LibreChat, Open WebUI, the Vercel AI SDK, LangChain, the AG-UI HttpAgent client, or a plain curl. Pick the agent with the model field, or with its name in the URL for AG-UI. There is no proprietary session protocol to learn.

The key in every example below is a workspace API key, minted in Studio under Settings → API keys. It is shown once, it looks like sbk_live_<selector>_<secret>, and it names the agents it may call. Send the whole string, not the masked preview the key list shows. See API keys for the format, the permissions and how to revoke one.

A workspace can hold many agents. For OpenAI and Anthropic, the request's model names the one that should answer, and GET /models lists the agent names so a client can populate a dropdown (chat itself never routes through /models, only through the POST endpoints below). AG-UI carries no model field: you pick the agent by putting its name in the URL, POST /agui/v1/agents/:name.

The OpenAI and Anthropic wires are stateless by default: you send the full messages[] array on every call, the same as calling OpenAI or Anthropic directly. There is no session id or continuation token to carry forward unless you opt in with a header (see Session keys). AG-UI works the other way around: its threadId is a durable session key, always on, so you send only the newest turn once a thread exists. See AG-UI below.

OpenAI Chat Completions

POST /openai/v1/chat/completions
Authorization: Bearer sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
Content-Type: application/json

{
  "model": "support",
  "messages": [{ "role": "user", "content": "What plans do you offer?" }],
  "stream": false
}
{
  "id": "chatcmpl-…",
  "object": "chat.completion",
  "created": 1735689600,
  "model": "support",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "We offer Free, Pro and Team plans." },
      "finish_reason": "stop",
    },
  ],
  "usage": { "prompt_tokens": 42, "completion_tokens": 9, "total_tokens": 51 },
}

Set "stream": true to get chat.completion.chunk server-sent events instead, ending with data: [DONE], the same as the OpenAI API. The response lists every tool call the turn made under choices[0].message.tool_calls. A turn that ran its tools and answered ends finish_reason: "stop"; a turn that stopped on a call somebody else has to resolve ends finish_reason: "tool_calls". See Tools and shared state.

Anthropic Messages

POST /anthropic/v1/messages
x-api-key: sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
Content-Type: application/json

{
  "model": "support",
  "max_tokens": 1024,
  "messages": [{ "role": "user", "content": "What plans do you offer?" }]
}
{
  "id": "msg_…",
  "type": "message",
  "role": "assistant",
  "model": "support",
  "content": [{ "type": "text", "text": "We offer Free, Pro and Team plans." }],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": {
    "input_tokens": 42,
    "output_tokens": 9,
    "cache_read_input_tokens": 0,
    "cache_creation_input_tokens": 0,
  },
}

This is the richer of the two wires: it carries first-class thinking blocks (the agent's reasoning, when the model produces any) and tool_use blocks with structured input. Its usage reports cache reads and writes alongside input/output tokens, on the response above and on the streaming message_delta. input_tokens excludes the cached counts, so the three numbers add up instead of double-counting. Set "stream": true for the same message_start / content_block_* / message_delta / message_stop events Anthropic's own API emits.

AG-UI

POST /agui/v1/agents/support
Authorization: Bearer sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
Content-Type: application/json

{
  "threadId": "thread-1",
  "runId": "run-1",
  "messages": [{ "id": "msg-1", "role": "user", "content": "What plans do you offer?" }],
  "tools": [],
  "context": [],
  "state": null
}

The response is always a Server-Sent-Events stream, even for a one-line reply: AG-UI has no non-streaming mode. Each frame is one AG-UI event, encoded with the protocol's own event encoder:

data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}

data: {"type":"TEXT_MESSAGE_START","messageId":"msg_1","role":"assistant"}

data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"We offer Free, Pro and Team plans."}

data: {"type":"TEXT_MESSAGE_END","messageId":"msg_1"}

data: {"type":"CUSTOM","name":"usage","value":{"inputTokens":42,"outputTokens":9,"cacheReadTokens":0,"cacheWriteTokens":0}}

data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1","outcome":{"type":"success"}}

threadId and runId are both required: you mint them, the agent echoes them back verbatim. There is no model field. You choose the agent with the :name in the URL, so GET /models has no AG-UI equivalent either.

Every reasoning block the model produces streams as its own REASONING_START / REASONING_MESSAGE_* / REASONING_END pair, and every tool call as TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END, plus a TOOL_CALL_RESULT once a server-run tool returns. AG-UI is the only one of the three wires with a dedicated event for a tool's result.

tools declares tools your frontend can execute (a modal, a browser API, anything that only makes sense client-side). See Frontend-executed tools. state seeds the agent's shared state before the run starts. See Shared state and generative UI.

Anything the protocol has no event for rides a CUSTOM frame, named by its name field. Besides usage above, a turn emits browser_live_view (the live-view URL of a browsing session, as soon as one opens) and guardrail (the decision a guardrail took on this turn).

Listing agents

GET /openai/v1/models
{
  "object": "list",
  "data": [{ "id": "support", "object": "model", "created": 1735689600, "owned_by": "stackbone" }],
}

GET /anthropic/v1/models returns the same names in Anthropic's model-list shape: { data: [{ type, id, display_name, created_at }], first_id, last_id, has_more }. Both are catalogs only: a client uses them to populate a model picker, never to route a chat call.

Both catalogs answer through the presented key: they list only the agents that key may chat with, so a client's model picker shows exactly what it can call.

When a call is refused

A refusal arrives before any token streams. The statuses below hold on all three wires, with one exception noted in the table; only the body shape differs.

Status When code
400 the body is not a valid request for this wire (no model, empty messages) none
401 the credential is missing, empty, unknown, revoked or expired none
403 the key is valid but was not granted the agent you asked for permission_denied, or permission_error on Anthropic
404 no agent answers to that name model_not_found, or agent_not_found on AG-UI
409 the session is waiting on a human decision (AG-UI streams this instead) approval_pending
503 the agent is registered, but its graph could not be built model_provider_missing or graph_build_failed

The OpenAI and AG-UI bodies carry that code. The Anthropic body carries a type and a message only, so branch on the status there. The 401 is covered in Authentication, the 403 in API keys, and the 409 in Tool approvals.

Read 503 as "the name is right, the agent cannot answer yet". model_provider_missing means no model credential reached the workspace.

Once a stream is open the status is already 200, so a later failure rides the stream: an error frame then data: [DONE] on the OpenAI wire, an error event on the Anthropic wire, and a RUN_ERROR event on AG-UI.

Point a third-party client at your box

Every client below needs the same two values: the base URL https://your-box.example.com/openai/v1 and a workspace API key. Mint the key in Studio under Settings → API keys, grant it agent:chat on the agents the client should reach, and paste the whole sbk_live_… string into the client's API-key field. The masked preview in the key list is not a credential and will be refused.

LibreChat

Add the box to the endpoints.custom list in your librechat.yaml, and put the key in STACKBONE_API_KEY in LibreChat's environment:

endpoints:
  custom:
    - name: 'Stackbone'
      apiKey: '${STACKBONE_API_KEY}'
      baseURL: 'https://your-box.example.com/openai/v1'
      models:
        default: ['support']
        fetch: true
      titleConvo: true
      titleModel: 'support'
      modelDisplayLabel: 'Stackbone'

fetch: true populates the model menu from GET /openai/v1/models, which already answers through the key, so the menu lists the agents that key may call and nothing else. Keep one name under default as the fallback for a box that is still starting.

Open WebUI

Open Settings → Connections, add an OpenAI API connection, and give it:

Field Value
URL https://your-box.example.com/openai/v1
API key your sbk_live_… key, in full

Save, then pick the agent by name in the model selector.

Vercel AI SDK

Use the OpenAI-compatible provider, which posts to /chat/completions under the base URL you give it:

pnpm add ai @ai-sdk/openai-compatible
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';

const stackbone = createOpenAICompatible({
  name: 'stackbone',
  baseURL: 'https://your-box.example.com/openai/v1',
  apiKey: process.env.STACKBONE_API_KEY,
});

const { text } = await generateText({
  model: stackbone('support'),
  prompt: 'What plans do you offer?',
});

curl

curl https://your-box.example.com/openai/v1/chat/completions \
  -H "Authorization: Bearer $STACKBONE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "support",
    "messages": [{ "role": "user", "content": "What plans do you offer?" }]
  }'

A 401 here means the key is unknown, revoked or expired, or that only the preview was pasted. A 403 means the key is good but was not granted the agent you asked for. See API keys for the full refusal table.

BUILT WITH ❤️ FROM CANADA AND SPAIN