@cogitator-ai/studio
Cogitator Studio: a local debugger for Cogitator agent projects. Chat with every agent of a project, approve the tool calls that need it, read the trace and the cost of each run, run workflows and rerun them from a node, watch a team of agents work through its task list on a live graph, and fork a run from any step to see what a different input or tool result changes.
Guide: cogitator.app/docs/getting-started/studio
Start it
In a project created by create-cogitator-app or cogitator init:
pnpm dev:studio # the script every generated project has
npx cogitator dev # the same, from @cogitator-ai/cli
It opens http://localhost:4321 (the next free port when it is taken). The studio finds the agents, workflows and swarms in src/cogitator.ts, the registry generated projects have: export the runtime as cogitator and the rest as agents, workflows and swarms.
What it does
- Overview: the project at a glance, with its runs, success rate, spend in tokens and dollars, p50 and p95 latency, activity over the last two weeks, and every agent and workflow with the model it runs on and how often it ran.
- Chat with any agent: streamed answers rendered as Markdown with highlighted code and LaTeX math (
\(...\),\[...\]and$$...$$, a single$stays a dollar), reasoning, tool calls as one line each that opens to its arguments and result, errors, stop, and threads grouped by day. A project without memory gets one from the studio, so threads keep their conversation, also after a restart. - Approvals: a tool call with
requiresApproval(or one a permission rule makes ask) waits in the chat with its arguments, why it asks and the rule behind it, until you approve it, reject it with a reason the model sees, or Always allow it, which saves the suggested rule shown next to the button (POST /api/approvals/:idwithremember: true). The chat marks it always allowed only once the runtime saved the rule. - Questions of tools: the studio does not answer a question a tool asks with
context.interrupt(). Such a run endsfailedwith an error that names each question (paused to ask confirm_plan ("Go ahead?")), and the memory tree shows the pause with its question. Fork the run from the step before and mock the tool's result to go on past it. - Traces: every run as a waterfall of model calls, tool calls and the runs agent tools start, nested under the call that started them, with the time, the tokens and the cost of each model call (priced from the model registry,
unpricedfor unknown models) and the total, counted live while the run goes. Click a span for its details: cached and reasoning tokens, the finish reason, the instructions, prompt and response of a model call, the arguments and the result of a tool call. The studio asks the runtime for this content for its own runs only, so other observers of the project still get spans without it. - Workflows: run one with an input state, watch the status and time of each node on its graph, read what each node returned (text an agent wrote is rendered as Markdown, the raw JSON is a click away), and rerun from a node, keeping what the nodes before it produced.
- Swarms and agent teams: run a swarm of the registry with an input, its agent runs nested under it with their traces and costs. A team shows as a live graph while it runs: the lead and its members with their status, their turns and the task each works on, an arrow for every pair that exchanged tasks or messages, the shared task list in pending, in progress and completed columns (click a task for its result), and the messages between the agents, marked as coming from agents, not from the user. The studio builds a fresh swarm from the configuration in
swarmsfor each run, with@cogitator-ai/swarmsfrom the project. - Todos: a run of an agent with
todos: trueshows its todo list in a Todos panel, updated live each time the agent callswrite_todos, and from the start of a run that goes on from a list kept in its thread. - Memory tree: the
Treebutton next to a chat (orT) opens a panel with every branch of the thread as its memory keeps it. Forks and replays branch off where they went on from, a branch a studio fork started is labelledfork, events (a model change, a pause for approval, an interruption) show in line, the active branch is highlighted and its last entry is markednext message, the one the next message goes after. Each entry links to the run that wrote it. The panel shows the newest 500 entries, and a memory without branches shows its entries as a list. - Rewind and fork a thread: every entry of the memory tree gets two buttons on hover: rewind the thread to it after a dry run that shows what changes, or fork it into a new thread from there. With file checkpoints on, both can restore the agent's workspace to that point too. Rewound turns stay in the chat, faded, and a forked thread links back to where it came from.
- Time-travel forks: pick a step of a finished run (before each tool call), change the input, add context or replace a tool result, and run on from there. The original and the fork are shown side by side, with the tool calls that differ highlighted. A fork of a run that wrote to memory goes on a branch of its thread from where the run started, so it shows in the memory tree and never in the conversation it split from.
- Plugins: every plugin of the project's runtime with its status, what it adds, its permissions and diagnostics, Reload and Enable or Disable, and the panels plugins add, each in a sandboxed frame, see Plugins.
- Inbox and Schedules: the routines of
experimental_triggers, what starts them and what they came to. Start the triggers, run a trigger now, pause and resume it, read results and failures, approve or reject a run that waits and answer its questions, see Routines. - History: runs and threads are kept in
.cogitator/studio/as JSON files, searchable by input, output and error and filtered by agent, workflow or kind. Runs a stopped studio left behind are marked failed. - Keyboard:
⌘K(Ctrl+K) opens a command palette over agents, workflows, the whole run history and actions,/searches the page,G OandG Rgo to the overview and the runs,JandKwalk the run list and the trace,Nstarts a thread,Topens the memory tree, and?lists every shortcut. - Themes: light and dark, following the system until you pick one, which the browser remembers.
Plugins
The Plugins page (in the sidebar and the command palette) lists the plugins of the project's runtime, the ones its experimental_plugins gives it: the core's own plugin, plugins in code, plugin folders and installed plugins. Each card shows the plugin's id, version, origin, manifest format, status (active, disabled, blocked, failed or unloaded) with its error, generation, whether it runs isolated in a process of its own, what decided that it is enabled, its dependencies, what it adds (tools, skills, agents, commands, hooks, MCP servers, LLM backends, middleware, panels and routines), its permission rules and its diagnostics. The page updates live whenever the runtime's plugins change.
- Reload builds a new generation of the plugin beside the old one and swaps them once it loaded. A reload that fails says so with its error, and the old generation goes on serving.
- Enable and Disable turn the plugin on or off for this runtime, over the settings files (managed settings still win), and load or unload what it adds. The core's own plugin is always on.
- A project that configures no
experimental_pluginsshows only the core's own plugin, and the studio never loads plugins into it, so installed plugins do not show up in a project that did not ask for them. Reload, Enable and Disable are not offered there.
Panels
A panel a plugin registers, ui.panels in its manifest or context.registerPanel({ id, title, html, handlers }) in code (see Studio panels), is listed under Panels in the sidebar and opens in a frame sandboxed with allow-scripts only, never allow-same-origin: the page runs with an opaque origin and cannot reach the studio, its API or its cookies. The page is served with a Content-Security-Policy that lets it run its own inline scripts and styles and show data: images, fonts and media, and load or fetch nothing from any address: no fetch, no external scripts, styles or images, no form posts. It also gets X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin, X-DNS-Prefetch-Control: off and a Permissions-Policy that turns off devices, sensors, payment, credentials, the clipboard, screen capture and fullscreen. The policy sandboxes the page also when its URL is opened on its own, and the sandbox keeps it from opening windows, navigating the studio's page and downloading files.
The page talks to its plugin through the parent window:
<script>
let next = 0;
const pending = new Map();
window.addEventListener('message', (event) => {
const data = event.data;
if (data?.type !== 'cogitator:response' || !pending.has(data.id)) return;
const { resolve, reject } = pending.get(data.id);
pending.delete(data.id);
if ('error' in data) reject(new Error(data.error));
else resolve(data.result);
});
function call(method, params) {
return new Promise((resolve, reject) => {
const id = ++next;
pending.set(id, { resolve, reject });
window.parent.postMessage({ type: 'cogitator:request', id, method, params }, '*');
});
}
</script>
A request is { type: 'cogitator:request', id, method, params }: id is a string (up to 200 characters) or a finite number the page picks to match the answer, method names one of the panel's handlers, and params is any JSON value, which the handler gets. The studio answers { type: 'cogitator:response', id, result } with what the handler returned as JSON, or { type: 'cogitator:response', id, error } with the message when the handler threw or the panel has no such handler. Only messages of the panel's own frame are answered: other windows and a page the frame navigated to on another origin are ignored, and an answer that comes after the frame was reloaded is dropped. Reload page reloads the frame, and a reload of the plugin reloads it with the new generation.
What a panel can still send out. No browser policy keeps a page from navigating its own frame, and a page can put what it holds into the address it goes to (location.href = 'https://example.com/?d=' + data). The studio's own pages are served with frame-src 'self', so the browser refuses a navigation of the frame to another site, and the studio closes a panel whose frame loads a second document: the frame is replaced by a notice, the plugin answers that page no more, and Open again loads the page anew. The navigation still starts before the panel closes, so a page can reach the studio's own address with data in it, and channels no Content-Security-Policy covers, such as the DNS lookups of a WebRTC connection, stay open. Treat a panel as able to send whatever its handlers return to the network: plugin authors should not return secrets to a panel, and users should open panels only of plugins they trust with what those handlers can read.
Plugin endpoints
| Request | Body | Answer |
|---|---|---|
GET /api/plugins | PluginsState: available, configured and plugins (PluginRecord[]) | |
POST /api/plugins/:id/reload | {} | PluginActionResult: results ({ id, ok, generation, error? }[]) and plugins |
POST /api/plugins/:id/enabled | { enabled: boolean } | PluginActionResult |
GET /api/plugins/:id/panels/:panel | The panel's page, text/html with the policy above | |
POST /api/plugins/:id/panels/:panel/call | { method: string, params? } | { result } |
Ids are URL-encoded (board%40code for board@code) and bodies are JSON, as for every other post. A bad body answers 400, as do a panel without the handler and disabling the core's own plugin, an unknown plugin or panel answers 404, a reload or an enable in a project without experimental_plugins 409, and a handler that throws 500, with its message as error. The event stream sends { type: 'plugins', plugins } whenever the runtime's plugins change.
MCP Apps
A tool with an MCP App (extension io.modelcontextprotocol/ui), such as a tool of an MCP server whose _meta.ui.resourceUri names a ui:// app, or a tool given an app with mcpApp(), shows its app under each of its calls in the chat and on the run page. The studio reads the app through the tool (tool.app.read()), serves its page from its own address and frames it sandboxed with allow-scripts only, so the app runs with an opaque origin, never the studio's, as a plugin panel does. The page is served with a Content-Security-Policy built from the origins the app declares in _meta.ui.csp: inline scripts and styles, data: images, fonts and media, connectDomains for fetch and WebSockets, resourceDomains for scripts, styles, images, fonts and media, frameDomains for nested frames and baseUriDomains for <base>, and nothing else. An app that declares none reaches no address. A declared value that is no origin (a keyword, a value that would end the directive) is left out. The features the app asks for in _meta.ui.permissions (camera, microphone, geolocation, clipboard write) go to its frame's allow attribute and its Permissions-Policy, every other one is off.
The studio is the app's host through the ext-apps AppBridge: it tells the app the call's arguments (ui/notifications/tool-input) and its result (tool-result, with the server's media blocks and its structuredContent), or that it failed (tool-cancelled), sizes the frame as the app asks (ui/notifications/size-changed) and opens the web links it asks for in a new tab. The tool calls the app makes (tools/call) go to the server the app came from, and only to tools open to apps (visibility with app, or none). A call is held to the agent's permissions, as a call of the agent would be: the runtime's and the agent's rules (the tool by its mcp__<server>__<tool> name too), hooks and guardrails decide, a denied call answers the app with an error, and a call a rule asks for, or of a tool that needs approval (its server marks it destructive, or the adapter's requiresApproval says so), waits for the person: the card shows The app asks to call with the arguments, and the call runs once they press Allow, while Deny answers the app with an error. A web page is a host MCP Apps asks to put a sandbox proxy on an origin of its own between itself and the app. The studio gets the same isolation without one: the app's own frame has an opaque origin and the policy comes as a header the page cannot change. A dedicated domain an app asks for is not honored.
| Request | Body | Answer |
|---|---|---|
GET /api/agents/:agent/tools/:tool/app | What the app asks of its frame: uri, csp, permissions, prefersBorder, no HTML | |
GET /api/agents/:agent/tools/:tool/app/view | The app's page, text/html with the policy above | |
POST /api/agents/:agent/tools/:tool/app/call | { name, arguments?, approved? } | { status: 'done', result }, or { status: 'approval', tool } before an allowed call |
An agent or a tool without an app answers 404, a call from an app that may call no tools 403.
Routines: Inbox and Schedules
A project whose runtime configures experimental_triggers gets two more pages in the sidebar and the command palette.
Schedules lists the routines, the ones in code, in the routine store and from plugins, each with its task, the agent or workflow it runs, its overlap rule, the thread it runs in and the runs of it going on or queued. Each trigger shows its kind, what it reacts to (the cron expression and time zone of a schedule, the events of a webhook), whether it is on, its next occurrence, when it last fired and how often. Run now fires a trigger by hand, as a person, with the optional text of the card as the event's text, and Pause and Resume turn it off and on. Below are the pending schedule entries, the cron triggers' and the tasks agents gave themselves with schedule_task, with Run now, Pause and Resume, and the latest 50 firings with their status, source, run and reason.
The studio never starts the trigger manager by itself, since routines may call real models: the triggers are started with Start at the top of the page and stopped with Stop, and a reload of the project leaves them stopped. Until then schedules do not fire, cron triggers have no schedule entry yet and Run now of a schedule entry is refused, while Run now of a trigger works either way.
Inbox lists what the routines came to, newest first: results, failures, approvals and questions, filtered by Unread, All (everything not archived) or Archived and by kind, and updated live. An item shows its summary and the whole result, its routine, trigger, source, run and thread (linked when the studio keeps that run or thread), and can be marked read or unread and archived. An approval is approved or rejected, with a reason the model reads, and a question is answered in text, sent as JSON when it parses as JSON. A request of a routine's workflow takes the answer its type asks for: approve or reject, one of its choices (sent as the choice's value), text, or a number for a rating. The run goes on and its outcome comes to the inbox as a new item, the answered one is marked answered and archived. An archived item that still waits can be answered, an answered one cannot, whatever its status. The sidebar shows the unread count next to Inbox.
An approval of a routine's run can also be answered through POST /api/approvals/:id, the route the chat's approvals use: an id the studio knows as a chat's approval is answered as before, else the route looks for an inbox item that waits on it, by the tool call id of an agent's approval (an Approve or Reject action of the item), the approvalId of a workflow's approval or the item's own id. An agent's item is answered with { decisions: { [toolCallId]: { approved, reason? } } }, every approval of the item when the item's id is given, a workflow's with { decision: approved }. The answer is { decided: true, inboxItemId, result }, or { decided: false } when nothing waits on the id.
Routine endpoints
| Request | Body | Answer |
|---|---|---|
GET /api/schedules | TriggersState: configured, started, routines, schedules and history | |
POST /api/schedules/start | {} | TriggersState |
POST /api/schedules/stop | {} | TriggersState |
POST /api/schedules/:id/run | {} | TriggersState, the entry fires on the next poll |
POST /api/schedules/:id/pause | {} | TriggersState |
POST /api/schedules/:id/resume | {} | TriggersState |
POST /api/triggers/:id/fire | { text? } | TriggerFireResult: status (started, queued, skipped), runId and more |
GET /api/inbox?status=&kind= | InboxListing: configured, items (InboxItemRecord[]) and unread | |
POST /api/inbox/:id/read | {} | The item |
POST /api/inbox/:id/unread | {} | The item |
POST /api/inbox/:id/archive | {} | The item |
POST /api/inbox/:id/respond | { decisions?, defaultDecision?, responses?, decision?, respondedBy? } | TriggerFireResult of the run going on |
A trigger id is <routineId>.<triggerId> (digest.morning). :id of run is a schedule entry, of pause and resume a schedule entry, a trigger or a routine: the entry of a routine's cron trigger is paused through the trigger, so the trigger's state says the same. status takes unread, read and archived separated by commas, or all, and lists every item but the archived ones when left out. kind takes result, approval, question and failure the same way, limit is up to 500 (100 by default). A project without experimental_triggers answers GET /api/schedules and GET /api/inbox with configured: false and nothing in them, every change with 409, as it does a start of the triggers that is not configured. An unknown trigger, schedule entry or item answers 404, a Run now of a paused entry or of triggers that are not started 409, a bad body or query 400. The event stream sends { type: 'inbox', change, unread } for every change of the inbox, change being { type: 'added' | 'updated', item } or { type: 'deleted', id }.
How it works
browser ──HTTP, SSE──▶ studio server (cogitator dev) ──IPC──▶ runtime host (child process)
React UI run store in .cogitator/studio your project, loaded with tsx
file watcher cogitator.observe(), TimeTravel,
WorkflowManager
- The runtime host is a child process that loads
.envandsrc/cogitator.tswith tsx and runs the project with its own runtime, memory and tools. It watches every run throughcogitator.observe(), so runs that agent tools start on the same runtime are seen too, and links them to the request that started them withAsyncLocalStorage. Forks use the project'sTimeTravelwith checkpoints kept on disk; workflows run on aWorkflowManagerwith file checkpoints, which is what reruns from a node start from. - The server keeps the runs and threads, prices model calls, puts nested runs under the tool call whose span was open when they started, and pushes every change to the browser over server-sent events.
- When a file under
src/,cogitator.yml,.env,package.jsonortsconfig.jsonchanges, the host is started again with the new code: the server and the browser stay, runs that were going are marked failed with the reason.
Security
The server listens on 127.0.0.1 and answers only requests for localhost, so another site cannot reach it through DNS rebinding, and refuses cross-site posts. --host 0.0.0.0 (or any other address) opens it to the network and requires the random token printed in its URL; the token is kept in an HttpOnly, SameSite=Strict cookie after the first visit.
API
import { startStudio } from '@cogitator-ai/studio';
const studio = await startStudio({ projectDir: process.cwd(), port: 4321 });
console.log(studio.url);
await studio.ready();
// ...
await studio.close();
| Option | Default | What it does |
|---|---|---|
projectDir | required | The project to run |
host | 127.0.0.1 | Interface to listen on; anything else needs the token |
port | 4321 | Port, the next free one unless strictPort |
token | random beyond loopback | Token requests must carry |
watch | true | Reload the project when its files change |
studioDir | .cogitator/studio | Where the history lives |
log | Receives the project's output | |
price | model registry | Prices a model call from its model and tokens |
The HTTP API the UI uses is open to scripts as well: GET /api/state, GET /api/runs?target=&kind=&q=, GET /api/runs/:id, GET /api/threads/:id, GET /api/threads/:id/tree for the memory tree of a thread, POST /api/threads/:id/rewind and POST /api/threads/:id/fork to rewind a thread or fork it at an entry, POST /api/chat, POST /api/runs/:id/stop, POST /api/approvals/:id, POST /api/runs/:id/fork, POST /api/workflows/:key/run, POST /api/runs/:id/rerun, POST /api/swarms/:key/run ({ input }), GET /api/stats for the totals of the overview, and GET /api/events for the event stream. The shapes are the types exported from the package (RunRecord, ThreadRecord, ThreadTree, TreeEntry, StudioStats, StudioEvent and the rest). A RunRecord carries memory (MemoryPlace: threadId, branchPoint, leafId) when its run wrote to memory. A ThreadRecord lists the runs a rewind took off its branch in rewoundRuns, and a forked one carries forkOf (threadId, entryId) and base, the messages it starts from. Every RunRecord has a revision that each change of the run raises, and token and reasoning events carry the revision they bring it to: a client that mixes snapshots with the event stream keeps the copy with the higher one. A swarm run has kind: 'swarm', and a team run carries team (TeamRecord: its agents, tasks, assignments and messages), which applyTeamEvent builds from the team:* events of the swarm.
License
MIT