The preview

The preview

npm run dev (i.e. curb dev) serves a browser preview on one port and opens it in your browser (default http://127.0.0.1:4000; it walks to 4001, 4002, … if the port is taken, so several agents run side by side; pass --no-open or set CURB_NO_OPEN=1 to skip the auto-open). Edits apply on save: saving a file under src/, a context/*.md or .env reloads the agent within seconds, without restarting curb dev (see Editing while it runs). The preview renders the same compiled tree two ways: as an interactive UI you can operate, and as ONE live conversation you can hold by typing, talking, or both. It is where you meet, drive, and debug the agent.

Editing while it runs

curb dev restarts the agent’s server process on every save, so the agent you talk to runs the code on disk, including the helpers your skills import and the keys in .env. The terminal prints one line per reload, naming the file:

 ✓ 14:25:26 dev   reloaded src/agent/agent.tsx in 1.2s
  • The preview stays on its conversation: it reconnects to the same recorded conversation (.data/conversations.jsonl), and your next message runs on the new code. With recording off (KUGEL_CONVERSATIONS_DB=off) it starts a new one.
  • A reload keeps the conversation, the skills it has open and its store data (createAgentStore), and never runs a tool again: a booking or an email your agent made happens once, however often you save. When something cannot come back (a store or a skill that no longer exists, store fields that are not JSON data), a line above the message field says what.
  • A reply that is being written finishes first, for at most 30 seconds; a longer one is cut off, and the terminal says so. A live call is never cut: the reload waits until the call ends, and a line above the message field says so. An MCP sign-in you opened holds the reload until the provider answers, for at most 5 minutes.
  • A finished call’s recordings (the audio under its messages and the call replay) do not survive a reload.
  • An edit that does not load or render (a syntax error, an AgentAuthoringError, a component that throws) leaves curb dev running: the preview shows the diagnostic in place of the agent, and the next good save brings it back.
  • A src/ or context/ folder created while curb dev runs is watched from then on.
  • MCP servers connect again on every reload. When a reload changes the conversation store (a KUGEL_CONVERSATION_SINK added to or removed from .env), the terminal names the new one.

The page

The conversation is on the left and Agent context on the right, under a top bar that names the agent (a picker when the project has several) and shows what curb check finds: No problems, or how many problems there are, which opens them (see Problems).

Nothing in the preview edits the agent; the code under src/agent/ does, and Copy for coding agent (see below) hands your coding agent a real conversation to work from.

Agent context

The Context tab shows what the model gets from your code, live:

  • Always available: the Instructions (your <SystemPrompt>; the row shows its first sentence and opens it in full, with what Curb adds to every message: the date and time line and, on calls, the rules for phone calls), the agent-wide Tools, and the MCP servers with their state. A server that needs a sign-in shows Sign in; one that could not connect says Not connected and expands to the error.
  • Skills, as the folders they live in: each top-level skill folder is a root, with the folders nested in it beneath. A skill’s row shows its description and says In use (the skill the agent is on), Read (in the model’s context from earlier), Locked (its route waits for a condition), or what it holds, such as 2 skills · 1 tool. The chevron in front expands it to its own tools, which work only while it is loaded, and its sub-skills; the row opens it. The skill in use starts expanded.
  • Built in: what the framework adds on its own, such as ending the call or opening a skill.

After each message, what entered the agent’s view because of it (a skill it read, a tool it can call now, changed instructions) carries a New mark until your next message.

Clicking a skill opens its page beside the conversation: its instructions, its documents, its tools and, for a locked skill, when your agent can open it. The page of the skill the agent is on is the conversation’s own: exactly what the model gets, including what the conversation changed (a finished procedure, a sign-in, the tools they unlocked). Every other page comes from the preview’s own copy of the agent, which browsing and test runs use so they never touch the conversation, and each page says which it is:

  • “In use right now: this is what the model gets”: the conversation’s page.
  • “In use from earlier”: the agent has the skill from earlier in the conversation; the page shows it as written, without the conversation’s changes.
  • “Not in use right now”: the agent reads the skill when the conversation needs it.

On a page, a <Procedure> renders as a stepper whose button advances it one step, the same transition the model would drive. Knowledge (<Text>, plain JSX text) renders as readable documents, and the system prompt and every multi-line <Instruction> and <Text> render as formatted text: paragraphs, headings, lists and line breaks, the structure the model reads (see “Writing prose” in authoring.md).

The address bar follows what is open, so a link opens the same view: /skill/<path> a skill’s page, /graph the graph, /system-prompt the instructions, /tools the tools and /diagnostics the problems.

Trying a tool

Try beside a tool opens a form built from its schema: a field per input, labelled with its name, its description as help, marked optional where it is. A tool with tryWith (see authoring.md) opens with those values filled in. When the agent called the tool in this conversation, Use these fills in the arguments it used. Run runs the tool’s onSubmit once with your values, exactly as if the model had called it, and shows what it returned, or why it failed, and how long it took. The run uses the preview’s own copy of the agent’s state, so it changes neither your agent nor the conversation; from then on that skill’s page shows the copy, and Undo last try on the page takes the run back. If the tool’s code calls an outside service, that call is real.

A tool the conversation unlocked cannot be tried, because test runs go to the copy, where it is still locked; ask the agent to use it instead.

So you can reproduce and debug any tool call yourself, without talking, and watch the agent’s state change the way it would mid-conversation.

The graph

Graph, above the agent’s context, opens a large view that starts at your agent and draws the skills it can open, from the live context and the routes in your code. The skill in use is highlighted. A dashed connector is a route that exists in the code but is locked until its condition holds, and its label names the condition. Click a skill to open its page, or the agent to read its instructions. An agent without skills has no graph, so it shows no button.

The conversation

There is ONE conversation: one transcript, one session, one authored system prompt. Type into the composer, or press Start call to attach voice (your real microphone, real speech) to the same thread: spoken and typed turns land in the same transcript and share all agent state. During a call the bar shows how long it has run and whether your agent is speaking or listening, the microphone button mutes you and its menu switches the input device, and End call hangs up. Keep typing and the conversation continues. While a call is live, typed messages run through the voice pipeline too, so the agent speaks its answer.

The title in the conversation header opens recent preview conversations, and New conversation starts one. A new conversation gets a fresh runtime and session; selecting an earlier one restores its transcript and agent state (the skills it had open, its store data) without running any of its tools again, so you can continue it.

  • What the agent did, in order inside its turn: “Open skill” for a skill it read, and a row per tool call with its first arguments and its outcome, which expands to the full arguments and result.
  • What the model read, under the newest reply (and under older ones on hover), with play and copy: the exact input of the model call that wrote the reply, its instructions, every message and the tools, downloadable as JSON. This is the ground truth for “what did the model actually see” and the fastest way to explain a wrong answer.
  • Rewind in both modes: Retry from here under your message, Go back to this reply under the agent’s, to retry a prompt or a routing decision.
  • Live transcription of what you are saying, as you say it (on a call).
  • Per-message audio: on call turns, replay exactly what was heard and said, your captured mic audio under your messages and the agent’s speech under its replies. On typed turns, play synthesizes the reply once with the configured voice.
  • Synchronized call replay: microphone and agent audio are combined into one seekable timeline that preserves pauses, overlap, and interruptions. Play, scrub, or download the complete call as one WAV.
  • Interruptions as on a real call: talk over the agent and it stops, and its model history is trimmed to the words that actually played. The generated but unheard continuation stays visible, muted, for debugging.

The system prompt stays singular: what you author is what both modes use. On spoken turns the framework appends its spoken-reply delivery rules at runtime; you never write those yourself, and typed turns never see them.

Problems

The curb check output (unreachable skills, duplicate tools, missing routing descriptions, oversized skills …) lives in the preview: the top bar says how many problems there are and opens them, each with a location, a concrete fix and a copy button for your coding agent. The same output appears in the server startup log; after a reload the terminal line counts the errors instead.

Copy for coding agent

Copy for coding agent in the conversation header copies a coding-agent-ready transcript of the conversation, together with the skill map and the tools, so you can hand a real session to a coding agent to improve the agent from what actually happened.

The transcript carries the system prompt this conversation sent to the model. A conversation restored from the list that has not had a turn since carries the prompt the store recorded for it. When there is none (the conversation never reached the model, or was recorded before prompts were), it carries the prompt in the code now, headed “System prompt (current code)”, because the conversation may have run with a different one.

Recorded conversations

By default every conversation is written to .data/conversations.jsonl in the project: each message with its time and kind (what the caller said, a context event, or input the framework generated), each tool call with its input and result, the context diff it caused, the system prompt the conversation’s model calls carried, and what continuing it needs (the skills it has open, its store data and its page info), updated after every turn. The file is append-only, one JSON record per line ({"t":"conversation",...}, {"t":"message",...}, {"t":"system_prompt",...}, {"t":"state",...}), and a later record for the same message, prompt or state replaces the earlier one when the file is read. Read it with curb conversations, jq or any editor for a durable record beyond the live preview. KUGEL_CONVERSATIONS_DB moves the file (a path relative to the directory the server runs in) or turns recording off (off).

If the server stopped in the middle of a write, the cut-off last line is dropped the next time the file opens, with a warning naming its line; every record before it is kept. Any other line that is not a record stops the server with the file and line number, so fix or delete that line, or move the file away to start a new history. An older Curb recorded to .data/conversations.db; that file is not read (the startup log says so once) and can be deleted.

The conversation list behind the conversation title reads this file: it offers the 20 most recent recorded conversations, plus the ones started on the current connection. It is read once when the preview connects, so reload the page to see conversations recorded since.

The server’s startup log names the store in force, in one line:

conversation store: JSONL /path/to/my-agent/.data/conversations.jsonl
conversation store: sink /path/to/my-agent/lib/conversation-sink.ts (KUGEL_CONVERSATION_SINK)
conversation store: off (KUGEL_CONVERSATIONS_DB=off)

Recording to your own store

To send transcripts somewhere else, set KUGEL_CONVERSATION_SINK to a module in your project (an absolute path, or one relative to the project root) whose default export is a ConversationSink:

// lib/conversation-sink.ts
import type { ConversationSink } from "curb";
import { db } from "./db"; // your own client; curb adds no driver

const sink: ConversationSink = {
  // Return a stable id. `metadata` is the session info your host sent with
  // `set_info`, read when the first message starts the conversation.
  async startConversation(agentId, metadata) {
    return db.insertConversation({ agentId, metadata });
  },
  // MUST replace the message already stored at (conversationId, seq).
  async recordMessage(conversationId, seq, message) {
    await db.upsertMessage({ conversationId, seq, message });
  },
  // Optional: the system prompt the conversation's model calls carried. Called
  // again whenever it changes, and replaces the one stored before.
  async recordSystemPrompt(conversationId, systemPrompt) {
    await db.setSystemPrompt({ conversationId, systemPrompt });
  },
  // Optional: what continuing the conversation needs (open skills, store data,
  // page info), as plain JSON. Called when it changes; replaces the one before.
  async recordState(conversationId, state) {
    await db.setState({ conversationId, state });
  },
};

export default sink;
KUGEL_CONVERSATION_SINK=./lib/conversation-sink.ts npx curb server

The server imports the module at startup, so a wrong path or a module without that default export fails the boot rather than recording nowhere. While a sink is registered nothing is written to or read from .data/conversations.jsonl: the conversation list then shows only the conversations started on the current connection, and reading older transcripts is up to your store.

When the store fails

The transcript is a record; the call is the product. A write your sink rejects never fails the turn or drops a live call. It is not dropped either:

  • Each rejection is logged at error level with the conversation id (once the sink has issued one) and the attempt number, and the message stays pending.
  • Pending messages are retried, in order, on the next change to the transcript, and once more when the session ends: when the connection that owns it closes, or when a call served by a curb build artifact ends. A direct-join or managed runner waits at most 10 seconds for that last attempt before ending the call, and logs it if it gave up waiting.
  • Whatever the sink still rejects then is logged at error level with the conversation id and the number of messages lost. That transcript is incomplete in your store.

What your sink has to guarantee, because of those retries:

  • recordMessage upserts on (conversationId, seq). A retry can repeat a write that actually landed, and a barge-in rewrites a message that is already stored; both must replace it, never add a second row.
  • startConversation is retried only after it rejected, so a rejection must mean no conversation was created.
  • Calls for one session never overlap and arrive in seq order, so a write that keeps failing holds back the ones after it. Curb puts no timeout on a call; bound your own.

Next steps