Agents & terminals
Listing agents
Needs agents.read.
import type { AgentInfo } from '@neurosquad/card-sdk'
const agents: AgentInfo[] = await card.agents.list()
const waiting = agents.filter((a) => a.kind === 'ai' && a.status === 'needs-input')
const one = await card.agents.get(agents[0].id)
render(waiting.map((a) => a.name), one.model ?? 'default model')Every AI agent and terminal in the card’s workspace (other cards are not agents):
| Field | Type | Meaning |
|---|---|---|
id | string | The card’s id on the canvas. |
name | string | What the user sees. |
harness | string | claude-code, codex-cli, opencode, qwen-code, shell-bash, shell-powershell, shell-cmd… |
kind | 'ai' or 'shell' | An AI agent or a terminal. |
status | AgentStatus | working, needs-input, finished, idle or exited. |
connected | boolean | An arrow connects it with your card. |
sessionTitle | string? | The title the agent gave its session. |
turnStartedAt | number? | Epoch ms the current turn started, while working. |
lastTurn | { startedAt, endedAt, endedAs }? | The last finished turn since the app started: epoch ms (startedAt may be null), and endedAs finished or needs-input — to catch up on turns your card missed while it was not running. |
model | string? | The model it runs on, when known. |
Statuses come from the same tracker as the rest of the app — for Claude Code from its own hooks
(for OpenCode and Kilo Code from their NeuroSquad plugin, for Hermes Agent from its lifecycle webhooks),
so needs-input (“waiting for your permission”) and finished are told apart exactly.
Events
card.agents.onStatus(({ agentId, status }) => render(agentId, status))
card.agents.onTurn(({ agentId, phase, at }) => render(agentId, phase, new Date(at)))
card.agents.onChanged((agents) => render(agents.length)) // added, removed or renamed
// Only one agent:
card.agents.onStatus((e) => render(e.status), agentId)agents.status—{ agentId, status, at }whenever a status changes.agents.turn—{ agentId, phase: 'start' | 'end', at }. A turn starts when a prompt is submitted and ends when the agent finishes or needs input.agents.changed—{ agents }, the whole new list.
The SDK subscribes while you have listeners. Each returns a function that removes it.
Reading output
Needs agents.output, and an arrow between your card
and the agent or terminal.
// The visible screen, as text (up to 500 lines).
const screen = await card.agents.readScreen(agentId, 80)
// The last reply, from the agent's own session log (Claude Code, OpenCode, Kilo Code, Hermes Agent). null for others.
const { text, at } = await card.agents.lastReply(agentId)
// Live output: ANSI colours stripped, delivered in chunks about every 100 ms.
const stop = card.agents.onOutput([agentId, shellId], ({ agentId: from, text: chunk }) => {
if (chunk.includes('FAIL')) render(`${from} printed a failure`)
})
stop() // when you no longer need it
render(screen, text, at)Calling these for an agent without an arrow fails with NOT_CONNECTED. readScreen of an agent
that is not running fails with UNAVAILABLE.
Prompting agents
Needs agents.prompt and an arrow to an AI agent
(terminals take commands instead).
import { isCardSdkError } from '@neurosquad/card-sdk'
try {
const delivered = await card.agents.prompt(agentId, 'Run the checkout tests and fix what fails.')
// 'sent' — submitted now
// 'queued' — the agent is working; it goes out when the turn ends
// 'inserted'— typed in only (submit: false); the user presses Enter
card.ui.toast(delivered === 'queued' ? 'Queued after the current turn' : 'Sent')
} catch (error) {
if (isCardSdkError(error, 'NOT_CONNECTED')) card.ui.toast('Draw an arrow from me to an agent')
else if (isCardSdkError(error, 'BUDGET_PAUSED')) card.ui.toast('The workspace is over its budget')
else if (isCardSdkError(error, 'USER_CANCELLED')) card.ui.toast('Not sent')
else throw error
}Options — card.agents.prompt(agentId, text, { submit, whenBusy }):
| Option | Default | Meaning |
|---|---|---|
submit | true | false types the text into the agent’s input without pressing Enter — the user reviews and sends it. Result inserted. |
whenBusy | 'queue' | While the agent is working: queue puts it in the agent’s prompt queue (queued), send submits anyway (sent), fail refuses with BUSY. |
What the app does with every prompt:
- It goes through the same path as the prompt queue and the budget: over the
workspace’s budget, it fails with
BUDGET_PAUSED(a person typing is never limited — your card is). - It shows on the arrow and in its arrow log with your card’s name.
- To an agent in dangerous mode, the app first shows the prompt in its own
window-wide dialog and waits for Send prompt; if the user declines —
USER_CANCELLED; if your card is not on screen —NOT_VISIBLE. - At most 6 prompts a minute per card — counted together with prompts sent through the agent’s
promptport — ≤ 20 000 characters each. - Queued prompts from cards are capped at 10 per agent, show which card they came from, and are dropped before they go out if the arrow, the permission, the card or its package is gone, or the agent was switched to dangerous mode.
A prompt is an instruction to something that can edit files and run commands. Never forward text
from the web, a file or another card into a prompt without showing it to the user first — that is
how prompt injection happens. Prefer submit: false when the text is not yours.
One agent’s run
Since NeuroSquad 0.1.264 (SDK 1.1). Needs usage.read and an
arrow to an AI agent — an agent without one fails with NOT_CONNECTED, a shell with INVALID_PARAMS.
At most 60 calls a minute per card. Check card.host.supports('agents.usage') first: an older app answers
METHOD_NOT_FOUND.
const run = await card.agents.usage(agentId, since /* epoch ms */, until /* optional, default now */)It returns the numbers of that agent inside the window, the same ones the app’s Usage section is built from. Your frame may be paused or unloaded while the agent works; the app keeps the status history itself, so the timing is right anyway.
| Field | Meaning |
|---|---|
prompts | Turns the agent was given. Answering a permission prompt continues the same turn; a finished turn without a single model request (a status blip) is not counted. |
requests | Model requests (API round-trips) the harness logged in the window. |
inputTokens / outputTokens | Uncached input; output with reasoning included (reasoningTokens is that part). |
cacheReadTokens / cacheWriteTokens | Cache traffic, where the harness and the provider report it. |
totalTokens | Input + output + cache read + cache write. |
elapsedMs | First prompt → last finish (→ now while a turn is under way). |
workingMs | Time the agent was working; waiting on the user is excluded. |
costMicroUsd | The harness’s recorded cost, else the list price, rounded up; null when no price is known (a model on the user’s own server included). |
costPartial | true when some requests had no price — the cost covers only the priced ones. |
usageReadable | false for a harness whose usage log the app can’t read (Amp, Cursor): every token field is null, timing still works. |
firstPromptAt, lastFinishedAt | Epoch ms, or null. |
model, provider, status, timingComplete, scannedAt | Context for the numbers. timingComplete is false when the app’s status history starts after since (the app or the agent started later); scannedAt is when the logs were last read. |
null means not reported: the harness’s log has no such number (some keep no cache writes or
reasoning counts), a local server sent no cache counts, or the app reads no usage log for that
harness at all (Amp, Cursor). Show it as “not reported”, never as 0. Token numbers are exact
integers.
Requests on a timeline
Since NeuroSquad 0.1.264 (SDK 1.2). Same permission and arrow as agents.usage; check
card.host.supports('agents.timeline').
const t = await card.agents.timeline(agentId, since, until /* optional */)
for (const r of t.requests) draw(r.startedAt ?? r.endedAt, r.endedAt, r.outputTokens, r.tools)| Field | Meaning |
|---|---|
requests[] | Every model request, oldest first: endedAt (always), startedAt (where the harness’s log records when it was sent, else null), tokens (null = not reported), tools (tool calls the model asked for). |
requestTimes | start-end — requests have send times (draw bars); end — completion times only (draw ticks); none — no readable log. |
statuses[] | The agent’s status as consecutive spans (working, needs-input, finished, idle, exited). |
turns[] | Prompt → finish; counted: false for a turn without a single request (a status blip). |
truncated | More than 2000 requests in the window: the newest are kept. |
Terminals
Needs terminals.write and an arrow to a terminal card
(bash, PowerShell or cmd).
// Run a command and wait for it to finish.
const result = await card.terminals.run(shellId, 'npm test -- --reporter=dot', { timeoutMs: 120_000 })
if (result.timedOut) render('still running after 2 minutes')
else render(result.exitCode === 0 ? 'passed' : `failed with ${result.exitCode}`, result.output)
// Or just type into it (and press Enter).
await card.terminals.write(shellId, 'git status', { submit: true })run(agentId, command, { timeoutMs? }) | write(agentId, text, { submit? }) | |
|---|---|---|
| Does | Runs one command and waits for the prompt to come back | Types text; submit: true presses Enter after it |
| Returns | { exitCode, output, truncated, timedOut } | nothing |
| Limits | timeout 1–120 s (default 120), output ≤ 64 KB; 30 commands a minute per card, shared with the terminal’s command port | 60 a minute |
exitCode is null in cmd, which does not report one. The user sees every command in the terminal
itself and on the arrow. Commands are not limited by the agents’ budget — a terminal is not an AI
agent — but they run with the user’s full rights: see the security checklist.