quickable
Guides

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 appears

A 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.

On this page