Sandboxed agent protocol
This page is the reference for what a sandboxed agent has to implement to run on the Agents page of Cosmonic Desktop.
To build your own agent step by step, follow Build a sandboxed agent first. This page is about sandboxed agents only; native coding agents such as Claude Code are covered in Connect coding agents.
Summary
A sandboxed agent is one WebAssembly component (a compiled program that can call only the interfaces it imports) that:
- Exports
wasi:http/handler@0.3.xand nothing else. - Imports only the interfaces on the allowlist: no outgoing HTTP, no sockets, no filesystem.
- Answers
GET /historywith200and a JSON body. - Answers
POST /taskwith{"task": "..."}by streaming NDJSON lines (one JSON object per line):delta,step,done, andfailed. It refuses aPOST /taskthat lacks thex-cosmonic-agent-sessionheader. - Writes each message of the conversation, including the person's, to its session journal as an
agent.step.v1entry. A reopened session is redrawn from that journal, not from the agent.
The minimal agent is the smallest complete implementation, in about 500 lines of Rust.
The interfaces
An agent reaches the host through the cosmonic:agent@0.3.0 WIT package. WIT (WebAssembly Interface Types) is the language components use to describe what they call, much like a .d.ts file describes a JavaScript module.
| Interface | What it gives the agent |
|---|---|
inference-types | The types model calls share: messages, chunks, completions, tool calls. |
models | open(alias): a model handle for an alias the host resolves. |
chat | chat(model, messages, ...): a streamed model call. |
tools | list and call: the tools the session's tool components provide. |
session | The session's journal and opaque state, with atomic, conditional commits. |
The package is published to ghcr.io/cosmonic-labs/cosmonic/agent from the awesome-cosmonic repository, where each interface's .wit file documents its full contract. To fetch it with wash wit fetch or wkg, map the cosmonic namespace to that registry. The repository's wit/wkg-registries.toml does this:
[namespace_registries]
wasi = "wasi.dev"
[namespace_registries.cosmonic]
registry = "ghcr.io"
[namespace_registries.cosmonic.metadata]
preferredProtocol = "oci"
[namespace_registries.cosmonic.metadata.oci]
registry = "ghcr.io"
namespacePrefix = "cosmonic-labs/"Set WKG_CONFIG_FILE to its path for both wash wit fetch and wash build. The variable replaces your tool's configuration rather than adding to it, which is why the file repeats the wasi namespace.
Imports and exports
When you add an agent, Desktop asks the daemon to read the image's WIT (the daemon's GET /v1/inspect?image= endpoint, which pulls the image if it isn't stored yet). Desktop refuses to save the agent unless every import matches this list, the same list Desktop's own agents must pass before they are signed:
| Package | Interfaces | Versions |
|---|---|---|
wasi:io | error, poll, streams | 0.2.x |
wasi:clocks | wall-clock, monotonic-clock, types | 0.2.x, 0.3.x |
wasi:random | random, insecure-seed | 0.2.x, 0.3.x |
wasi:cli | environment, exit, stdin, stdout, stderr, terminal-input, terminal-output, terminal-stdin, terminal-stdout, terminal-stderr | 0.2.x |
wasi:http | types (types only: it cannot send a request) | 0.3.x |
cosmonic:agent | inference-types, chat, models, tools, session | 0.3.x |
The only export allowed is wasi:http/handler@0.3.x.
Anything else is refused, and the form lists each import or export that is the reason. The usual ones are wasi:http/client, wasi:http/outgoing-handler, wasi:sockets, and wasi:filesystem. You can read an image's imports yourself with wash inspect <file or image>.
Tool images are not held to this list, because reaching the network is what a tool is for. Desktop still reads them when you save, so a mistyped tool image is caught. A tool may also import cosmonic:agent/session to keep its own durable state.
Importing tools
- An agent that imports
cosmonic:agent/toolscan run with no tools. When you list none, Desktop declarestoolsfor the session and the host serves an empty list:listreturns no tools andcallanswersunknown-tool. So the publishedcosmonic:agent/agentworld works as it is. Naming only the interfaces you call in your own world is still a good habit, because it keeps the capabilities an operator reviews down to what your code uses. - More than one tool needs labelled imports. When several tool components each export
cosmonic:agent/tools, the agent must import each one under an(implements ...)label naming its component. An unlabelled import cannot tell the tools apart, and the session does not start.
The workload
Each session is one workload, the unit of deployment in Cosmonic Desktop, Cosmonic Control, and open source wasmCloud. A workload is a set of components plus the host interfaces they are granted. For an agent you add, Desktop builds it like this:
apiVersion: runtime.wasmcloud.dev/v1alpha1
kind: Workload
metadata:
name: agent-session-<session id>
namespace: default
labels:
desktop.cosmonic.com/agent-session: <session id>
desktop.cosmonic.com/agent: custom-<your agent's id>
spec:
components:
- name: agent
image: <the image you gave>
poolSize: 1
localResources:
allowedHosts: [] # the agent has no network of its own
- name: <tool name> # one per tool you listed
image: <tool image>
poolSize: 1
localResources:
allowedHosts: [<the hosts you listed for it>]
hostInterfaces:
- namespace: wasi
package: http
interfaces: [handler]
config:
host: agent-<session id>.localhost
- namespace: cosmonic
package: agent
version: 0.3.0
interfaces: [session]
config:
components: agent
tool-components: <tool name>,<tool name> # only when the agent has tools
- namespace: cosmonic
package: agent
version: 0.3.0
interfaces: [inference-types, models, chat]
config:
components: agent
backend: <the model source picked for the session>
model: <the model picked for the session>
- namespace: cosmonic # only when the session has no tool component
package: agent
version: 0.3.0
interfaces: [tools]
config:
components: agent- The agent component gets no outbound hosts. It reaches the model and its session store only through the host.
- Every
cosmonic:agententry is granted to theagentcomponent alone (components: agent), so a tool cannot read the conversation or spend the model source's budget. - Each tool named in
tool-componentsgets a session store of its own, separate from the agent's. See Durable state for tools. - Each tool reaches only the hosts you listed for it. By default (Ask first under Network, which appears when you start a session whose tools list a host), Desktop also blocks a tool's first request to each host until you allow it. With Allow listed hosts, tools reach their listed hosts without asking; the list still bounds them either way.
- The session stores are scoped to this workload and deleted with it.
Desktop also sets annotations on the workload for the creation time, the manifest format, each tool's hosts, and whether to ask before a tool reaches a new host.
None of this makes the agent's own code trustworthy. An agent you add is code you chose to run, and Desktop labels it with your name for it and its image.
Requests
Desktop's main process sends every request through the daemon's local ingress on 127.0.0.1, with the Host header set to the session's host, agent-<session id>.localhost. The method and path come from a fixed table, so an agent receives only these two requests.
GET /history
Desktop's readiness check. After a session starts or restarts, Desktop asks every 1.5 seconds for up to 90 seconds. The session is ready on the first 200 response whose body is JSON other than null, false, 0, or "".
Desktop does not read the body. Desktop's built-in agents answer {"steps": [...]} from their journal.
POST /task
One turn of the conversation:
POST /task HTTP/1.1
Host: agent-3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b.localhost
Content-Type: application/json
x-cosmonic-agent-session: 1
{"task": "Where is the International Space Station right now?"}- The body is a JSON object with one string field,
task. - A session can start without a first message. Desktop then sends nothing until the person writes one.
- Desktop sends one turn per session at a time.
Refuse a POST /task without x-cosmonic-agent-session. Goose, Pi, and the minimal agent answer 403. The ingress has no other authentication, and a web page can send a cross-origin POST without asking first. A custom header forces the browser to ask first (a CORS preflight), which the ingress never answers, so a web page can never deliver one. Apply the same check to anything else your agent does that changes state.
The response:
- A status other than
200fails the turn. Desktop shows up to the first 2 KB of the body as the reason. - A
200body is NDJSON (application/x-ndjson): one JSON object per line, each ended by\n. Stream the lines as the turn happens. - A failure after the
200goes in the stream, as afailedline. - Closing the stream ends the turn.
- When the person selects Stop, Desktop drops the connection. Treat it as a cancellation; dropping the model's chunk stream cancels generation at the host.
Limits
| Request | Limit |
|---|---|
GET /history | 30 seconds per request; 32 MB per body |
POST /task | 10 minutes per turn, wall clock (not an idle timeout) |
POST /task | 8 MB per line; 64 MB per turn |
Response lines
Each line is a JSON object with a type. Desktop reads four types and ignores any other line, including one that is not JSON.
delta
{"type": "delta", "text": "The ISS is over"}Reply text as the model produces it. Desktop shows the deltas as a reply in progress until the next step replaces them.
step
{
"type": "step",
"role": "assistant",
"text": "",
"blocks": ["toolRequest"],
"tools": ["iss_position"]
}One whole message. The fields Desktop reads:
| Field | Type | Meaning |
|---|---|---|
role | string | assistant, user, or tool for whose message it is; error for a failed turn. |
text | string | The message text, rendered as untrusted Markdown: no images, no links. |
blocks | string[] | The kinds of content in the message: text, toolRequest, toolResponse, image, thinking, error, or your own. |
tools | string[] | The tools an assistant message calls, by name. |
images | string[] | Images in a tool result, only as data:image/png, jpeg, gif, or webp base64 URLs. Anything else is dropped. |
imagesDropped | boolean | true when you left an image out, so Desktop can say so. |
How Desktop draws a step:
role: "error", orerrorinblocks: an error in the conversation.toolResponseinblocks: a tool's result, folded under the call before it.role: "assistant"withtoolsand notext: a tool call.- Any other
assistantstep: a reply. - Anything else: the person's message.
Don't stream the person's message back as a step, because Desktop shows it as soon as it is sent. Do write it to the journal. A role of notice is reserved for Desktop; from an agent it is drawn as an ordinary reply.
done
{
"type": "done",
"input_tokens": 1820,
"output_tokens": 96,
"model": {"alias": "default", "provider": "cosmonic", "resolved": "gpt-4o-mini"}
}The turn finished. Desktop reads input_tokens, output_tokens, and model, and ignores other fields. Goose, Pi, and the minimal agent also send answer, turns, and context_messages, which Desktop does not use. What a reply cost is not taken from this line: Desktop reads that from the host's own record of model calls.
failed
{"type": "failed", "error": "the model refused the request: ..."}The turn failed after the 200. Desktop shows error as the reason. Write an error step to the journal as well, so the failure is still there when the session is reopened.
The conversation
The conversation on the Agents page is the session's journal, an append-only log the host keeps for the session. It is not anything the agent sends to the ingress. When a session is opened, Desktop reads the journal from the daemon (GET /v1/workloads/{namespace}/{name}/session), a page at a time, and draws:
- every entry of kind
agent.step.v1whose data is a JSON object with the fields of astepline (withouttype), timed by when the host accepted it; - every entry of kind
agent.context-reset.v1, as a note that the agent started a fresh context (write one when your agent cannot read the state it saved, for example after an upgrade changed its format); - nothing else. Entries of your own kinds stay in the journal and are skipped.
So an agent writes, through cosmonic:agent/session:
- the person's message, as an
agent.step.v1entry withrole: "user", when the turn starts; - each message it streams as a
step, as the same kind of entry; - its working state (the model's context, in any format it likes) as the session's opaque state, committed atomically with the entries that go with it.
session.wit describes the commit protocol in full: conditional commits, an agent.op.v1 entry before each model or tool call and an agent.op-result.v1 entry after it, and what to do on restart with a call whose result was never recorded. The host stamps each entry's author and time; the agent cannot.
The session's title also belongs to the host. Desktop sets it from the first message and the person can rename it.
State across versions
The session's state outlives your image. An update, a model switch, or a rollback can hand it to a different version of your agent. Desktop refuses to replace a session while a call it journaled is still open, but it cannot read your state, so compatibility is the agent's job:
- Version your state. Put a format version in what you save.
- Never overwrite a newer format. If the state comes from a newer version than yours (after a rollback), start a fresh context with an
agent.context-reset.v1entry rather than rewriting state you cannot read. - Migrate older formats you know, rather than resetting them.
- Carry unresolved calls forward. A call with an
agent.op.v1entry and noagent.op-result.v1was cut off. Record it as unresolved, never repeat it, and tell the model it did not complete. - Repair dangling tool calls before sending the context to a model. Most providers refuse a tool call that has no result.
Models and tools
- Models. Open a model with
models.open(alias), then callchat.chatwith the handle. Built-in agents open the aliasdefault. The session's binding names the model source and model the person picked, and the host resolves the alias against it, so the agent never names an endpoint, holds a key, or chooses what to spend. - Tools. List and call tools with
tools.listandtools.call. The call goes to the tool component through the host; the agent never reaches the tool's hosts itself. When a tool's request is blocked because the person has not allowed that host yet, the tool reports the refusal, and Desktop shows its own prompt from the host's record, not from anything the agent says.
Durable state for tools
A tool component can keep state across restarts by importing cosmonic:agent/session@0.3.0, the same interface agents use: a journal, an opaque state, and conditional commits.
Its own store
In an agent session, Desktop grants every tool it runs a store of its own. The host scopes each store to one session and one component, so a tool can never read:
- the agent's conversation;
- another tool's store;
- another session's store.
The host resolves every call by the component making it, not by the workload, so sharing a workload with the agent does not give a tool the agent's store.
The grant
The grant is a tool-components key on the same cosmonic:agent session entry that grants the agent its store:
- namespace: cosmonic
package: agent
version: 0.3.0
interfaces: [session]
config:
components: agent # the agent's store: the conversation
tool-components: search,notes # each named tool gets its own storeDesktop writes this key for you, naming every tool you list. It matters only if you write a workload yourself:
- Both keys belong on one entry, not two, because the runtime folds every entry bound to the same plugin into one configuration.
- A component named in both keys is refused when the workload starts, so which store a component reaches is never ambiguous.
- A tool that does not import the interface is unaffected.
- A component that imports the interface without a grant does not start.
Encryption and deletion
Each session's data is sealed with that session's own key, and deleting the session deletes the key with its stores. The master key that protects the session keys lives:
| Platform | Where the master key lives |
|---|---|
| macOS | The login Keychain. |
| Windows | A file protected by Windows DPAPI for your user, in a local (not roaming) folder. |
| Linux | The Secret Service (GNOME Keyring or KWallet). On a machine without one, a file readable only by your user. |
Store limits
| What | Limit |
|---|---|
| One saved state or journal entry | 8 MiB |
| One store | 64 MiB |
A write past either limit is refused with the too-large error, which names the limit, and nothing from that commit is written.
Using it
- On start, call
currentto read back the state and the version you last saved. - To save, call
commitwith the new state, conditional on the versioncurrentreturned. If something else wrote in between, the commit comes back stale instead of overwriting it. - Keep the state small. Save what the tool needs to resume, not a cache of everything it has seen.
The comments in session.wit describe every call and error.
Logs
What the agent writes to stdout and stderr is the workload's log. When an agent does not start, Desktop offers Open logs on the session.
Adding an agent in Desktop
You can add an agent in two places, with the same form:
- Add a new agent… in the Agent dropdown on the Start a session screen of the Agents page;
- Add an agent in the Your agents card under Settings → Sandboxed agents, which is also where you edit or remove agents.
| Field | What to enter |
|---|---|
| Name | How the agent appears in the Agent list. Up to 64 characters, unique. |
| Image | The agent component's OCI reference. Desktop checks its imports before saving. |
Desktop's built-in registry is a convenient place to push an image from your machine:
wash oci push --insecure oci.localhost:8200/apps/my-agent:0.1.0 my_agent.wasmPush each rebuild under a new tag; Desktop pins an image to the build its tag first pointed to.
Changing an agent's image affects new sessions only. Sessions you already started keep the images they started with, and Desktop stops sending them messages. Removing an agent leaves its sessions in place, unmessaged, until you add it again.
Sessions Desktop doesn't recognize
A session started outside the Agents page (for example from a manifest applied with the CLI or over MCP), or whose images no longer match a saved agent, shows Desktop doesn't recognize this agent. Select Add this agent to save it as one of your agents. Desktop checks its imports first, may restart the session in the shape described in The workload Desktop runs, and keeps the conversation.
Checking your agent
- Build the component and push it to a registry Desktop can pull from.
- Add it with Add a new agent…. Check and save runs the import check.
- Start a session. If it never gets past starting, it is not answering
GET /historywith200and JSON within 90 seconds: select Open logs. - Send a message, then select another session and come back. If the conversation is gone, the agent is streaming steps without writing them to its journal.