An LLM builds a visual companion
Bootstrap a namespace, hand your agent the MCP endpoint, and let it create and hot-modify a visual the user interacts with.
The companion shape: an LLM renders its thinking as a live visual, the user looks and
reacts in the visual, the LLM reads the reaction and updates in place. This guide is the
concrete end-to-end path against a running deployment ($BASE, e.g.
http://localhost:8080).
1. Bootstrap once (human or script)
Signup is bring-your-own-key and the bearer mint runs over one authenticated NATS
connection — a ~20-line script (identical in shape to what qctl smoke run does):
// bootstrap.mjs — node bootstrap.mjs http://localhost:8080
import { jwtAuthenticator, wsconnect } from "@nats-io/nats-core";
import { createUser } from "nkeys.js";
const base = process.argv[2];
const user = createUser();
const signup = await (
await fetch(`${base}/v1/signup`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ownerUserPublicKey: user.getPublicKey() }),
})
).json();
if (!signup.ok) throw new Error(JSON.stringify(signup.error));
const { ns, ownerUserJwt, ownerInboxPrefix } = signup.data;
const nc = await wsconnect({
servers: `${base.replace(/^http/, "ws")}/nats`,
authenticator: jwtAuthenticator(ownerUserJwt, new TextEncoder().encode(user.getSeed())),
inboxPrefix: ownerInboxPrefix,
});
const rep = await nc.request("api.session.token.owner", JSON.stringify({ ttlSeconds: 3600 }));
const token = JSON.parse(new TextDecoder().decode(rep.data));
await nc.close();
console.log(JSON.stringify({ ns, bearer: token.data.bearerJwt, exp: token.data.exp }));Keep the owner seed; re-run the mint whenever the bearer expires. To give an agent
patch-only power (it can modify material but cannot delete apps or mint sessions), send a
second locally-generated public key at signup (materialUserPublicKey) and mint
api.session.token.material with that credential instead.
2. Point the agent at MCP
The MCP endpoint is $BASE/mcp (streamable HTTP, stateless) with the bearer as a header.
For example, for Claude Code:
claude mcp add quickable "$BASE/mcp" \
--transport http \
--header "Authorization: Bearer $BEARER"tools/list returns one tool per address — the same 18 documented in the
API reference, schemas generated from the contract.
3. The agent creates and fills the visual
Typical first calls (tool names, with their JSON arguments):
api_app_create { "name": "companion" }
api_material_put { "app": "companion", "path": "app.tsx", "src": "<the TSX below>" }
api_app_info { "app": "companion" } ← poll until currentBuildHash appearsA minimal companion visual — it renders shared state and turns user clicks into realtime
intents (the @quickable/bridge import is injected by the platform inside the sandbox):
import { rt, state } from "@quickable/bridge";
import { useEffect, useState } from "react";
export function App() {
const [snap, setSnap] = useState(state.get());
useEffect(() => state.subscribe(setSnap), []);
const doc = (snap.state ?? {}) as Record<string, unknown>;
return (
<main style={{ fontFamily: "sans-serif", padding: 16 }}>
<h1>{String(doc.title ?? "…")}</h1>
<p>{String(doc.body ?? "")}</p>
<button
type="button"
onClick={() => rt.intent("set", { key: "comment", value: "look at the title again" })}
>
Comment on this
</button>
<p>rev {snap.rev} — comment: {String(doc.comment ?? "none")}</p>
</main>
);
}The user opens $BASE/a/<ns>/companion. Every api_material_put /
api_material_patch triggers a rebuild and the open page swaps the sandbox in place.
4. Two-way: the agent reads reactions and edits cheaply
The deployed realtime reducer is a shared JSON document with four intents (set, merge,
inc, del) — the button above writes comment into it. The agent reads it back and
reacts:
api_rt_resume { "app": "companion", "fromRev": 41 }
→ { "state": { …, "comment": "look at the title again" }, "rev": 42, "deltas": [ … ] }fromRev makes the read incremental — the reply carries only the moves after the revision
the agent already has (native NATS clients get the same data pushed on evt.<app>.rt.>
with zero polling). The agent can also write into the same document with api_rt_intent
(e.g. set {key:"status", value:"revised"}) so the user sees its acknowledgement live.
To change the visual itself, patch by anchor instead of re-sending the file:
api_material_get { "app": "companion", "path": "app.tsx" }
→ content annotated with // @b:<32-hex> anchors, plus baseRev
api_material_patch { "app": "companion", "path": "app.tsx", "baseRev": 7,
"ops": [ { "op": "replace", "b": "<anchor>", "src": "…new block…" } ] }A stale baseRev returns only the conflicting blocks with fresh anchors — the repair loop
costs hundreds of tokens, not a re-read.