@colixsystems/agents-client
v0.3.0
Published
Scoped client for the AppStudio workspace-AI-agent runtime API. Used by widgets through the injected WidgetContext.agents.
Readme
@colixsystems/agents-client
Scoped client for the AppStudio workspace AI agent runtime API (sc-7991). It is a communication-plane sibling of @colixsystems/translation-client, @colixsystems/notifications-client, and @colixsystems/datastore-client. It is a standalone fetch-based client you instantiate with createAgentsClient({ baseUrl, getToken, getTenantId }).
Why this is a package and not host code. The web Player and the exported Expo app instantiate the same package and inject it into
WidgetContext.agents. That shared instantiation is what makes web↔native parity structural rather than a convention two hosts have to remember (CLAUDE.md §8). Widgets never import this package — they call the SDK hookuseAgent()from@colixsystems/widget-sdk, which readsctx.agents.
What an agent is — and what this client cannot do
A workspace agent is a name plus a system prompt the workspace author owns (sc-7989). This client talks to the runtime that answers a turn (sc-7990).
The agent's behaviour lives entirely on the server. A turn is assembled there from the stored system prompt plus the stored transcript, so:
send({ content })transmits onlycontent. A caller cannot supply a system prompt, a transcript, a model, or a temperature — not because they are filtered out, but because nothing else is ever put on the wire.- The system prompt is never returned on any surface, and it is not a message:
roleis only ever"user"or"assistant". - A prompt is not a secret store. No API here returns it, but the model can be talked into repeating it — anything in a prompt can end up in a reply that widget-controlled code reads. Never put credentials, or data the app's own users may not see, in an agent's prompt.
- The transcript is the server's. This client reads it back; it cannot rewrite it.
Every send spends the workspace's real AI credits, which is why it is the only call here that can answer 429 AI_QUOTA_EXCEEDED.
Status
v0.1.0 — pre-publish. Not yet published to npm.
Wire format — snake_case, no transform
The wire format is snake_case in both directions (REQ-GEN-09) and that is the client contract. This client does no camelCase↔snake_case transform. Bodies are sent snake_case verbatim; the { data, meta } list envelope and every row are returned snake_case verbatim. The only caller-facing camelCase is the JS method names and the factory option names.
Public API
import {
createAgentsClient,
AgentError,
ValidationError,
ForbiddenError,
NotFoundError,
AgentDisabledError,
RateLimitedError,
QuotaExceededError,
GenerationFailedError,
UnavailableError,
TimeoutError,
CancelledError,
ServerError,
TURN_TIMEOUT_MS,
} from "@colixsystems/agents-client";
const agents = createAgentsClient({
baseUrl: "https://api.example.com/api/v1",
getToken: () => `Bearer ${jwt}`,
getTenantId: () => tenantId,
});
const thread = agents.conversations(agentId);
const conversation = await thread.create(); // POST .../conversations
const mine = await thread.list({ limit: 20 }); // GET .../conversations
await thread.remove(conversation.id); // DELETE .../conversations/:id
const messages = thread.messages(conversation.id);
await messages.list({ limit: 50 }); // GET .../messages
const turn = await messages.send({ content: "Hi" }); // POST .../messages
// turn -> { conversation_id, user_message, reply }A conversation belongs to the caller — an app user in the published app, or a studio user previewing the widget on the Builder canvas. Another user's conversation id is a 404, never a 403, so one caller cannot even detect another's threads.
Errors
Every rejection is an AgentError subclass carrying the backend's own code verbatim, so codes that share an HTTP status stay distinguishable:
| Code | Status | Meaning |
| ---- | ------ | ------- |
| INVALID_MESSAGE | 400 | Empty, or past the length cap. |
| ACTOR_REQUIRED | 403 | Authenticated, but cannot own a conversation (an API key). |
| AGENT_NOT_FOUND | 404 | The bound agent is gone. |
| CONVERSATION_NOT_FOUND | 404 | A stale thread — open a new one. |
| AI_AGENTS_DISABLED | 404 | The feature ships dark for this workspace. |
| AGENT_DISABLED | 409 | The agent exists but the author turned it off. |
| RATE_LIMITED | 429 | Too many turns — retry shortly. |
| AI_QUOTA_EXCEEDED | 429 | The workspace is out of AI credit. Retrying will not clear this — say so rather than offering "try again". |
| AI_GENERATION_FAILED | 502 | The provider did not answer. The user's message is still in the transcript. |
| AI_UNAVAILABLE | 503 | No AI provider is configured for the workspace. |
| NETWORK | — | The request never reached the server. Raised client-side. |
| TIMEOUT | — | The client stopped waiting. Raised client-side, and not a failed turn — see below. |
| CANCELLED | — | options.signal aborted (a "stop" control). The user's own choice — render no error. |
AI_QUOTA_EXCEEDED subclasses RateLimitedError, so a caller that only branches on the rate-limit class still behaves sanely — but it should branch on code to avoid telling a user to retry something that cannot succeed.
Timeouts
Reads carry a 10 second ceiling and are retried with backoff. send does not:
one turn may run for minutes, because the server allows a provider call per
tool hop, so it waits TURN_TIMEOUT_MS.
That number is not a guess. The turn route grants itself a 330s budget
(withRequestTimeout in ai-agent.routes.ts, the value nginx's
proxy-read-timeout is tuned to), so the server answers by then — with a reply
or with its own clean timeout. TURN_TIMEOUT_MS sits just above it so that
answer wins the race, which makes a client TIMEOUT a backstop for a wedged
chain rather than the ordinary "slow model" case it used to be.
A TIMEOUT is a client-side abort and does not cancel the turn: the server
finishes it and the workspace is billed. Never report it as a network failure —
that told a perfectly connected user they were offline.
send accepts { signal } so a "stop" control can end the wait, rejecting
CancelledError. The same caveat applies, and it is the reason the affordance
should be labelled stop, not cancel: nothing is called off, and the reply is
still paid for. A cancel is the user's own action, so show no error for it.
A send is never retried automatically — timeout, cancel or transport failure —
because a retried turn would charge the workspace twice.
Licence
MIT
