Tools for agents (MCP)
A card can give agents new abilities. Declare a tool in the manifest, implement it in the card, and every agent connected to the card by an arrow sees it in its tool list — through the MCP server the app already runs for its agents (Claude Code, Codex, Qwen Code). No permission is needed: the arrow the user draws is the consent.
Declare
"tools": [
{
"name": "run_tests",
"title": "Run the tests",
"description": "Runs the project's tests in the connected terminal and returns the failing tests with their messages, or 'All tests passed'.",
"inputSchema": {
"type": "object",
"properties": {
"filter": { "type": "string", "maxLength": 200, "description": "Only tests whose name contains this" }
}
},
"timeoutMs": 120000
}
]The agent sees it as <card name with - → _>_<tool> — test_radar_run_tests for a card named
test-radar — with your inputSchema plus an optional card argument the app adds (an id or a name,
to pick one card when several copies are connected). The description reaches the model prefixed with
[Custom card "<displayName>" by <author>, community code]. If two packages would produce the same
name, the one installed first keeps it and the later one gets _2, _3.
Names the app refuses at install: an exposed name equal to one of NeuroSquad’s own tools
(canvas_spawn_card, terminal_send_keys, browser_navigate…), any name containing __ (reserved
for MCP servers the user installs), and a card name that is a built-in family such as telegram,
squad, ports or mcp (see reserved names). An update that adds a
tool or rewords a tool’s description asks the user again.
Write the description for a model: what the tool does, when to use it, and what it returns. Keep
arguments few and constrained (enum, maxLength, minimum…); the app validates them before your
card sees them.
Implement
import { toolError } from '@neurosquad/card-sdk'
card.tools.handle<{ filter?: string }>('run_tests', async ({ filter }, call) => {
call.progress('running the tests…') // shown on the arrow
const terminal = (await card.agents.list()).find((a) => a.kind === 'shell' && a.connected)
if (!terminal) return toolError('Connect a terminal card to the Test radar card first.')
const result = await card.terminals.run(terminal.id, `npm test -- ${filter ?? ''}`, {
timeoutMs: 110_000
})
if (call.signal.aborted) return // the agent gave up; nothing is sent
return result.exitCode === 0 ? 'All tests passed' : result.output
})The handler gets the validated arguments and a call:
call. | What |
|---|---|
callId | The call’s id. |
tool | The tool name as in your manifest. |
agent | { id, name } of the calling agent. |
deadline | Epoch ms after which the app has given up. |
signal | An AbortSignal, aborted on cancellation or at the deadline. |
progress(message) | A short line (≤ 200) shown on the arrow while you work; throttled to 4 a second. |
What you return becomes the tool result:
| Return | The agent gets |
|---|---|
| a string | that text |
undefined | OK |
| any other JSON value | pretty-printed JSON text |
toolText(text) | text (same as a string) |
toolImage(base64, 'image/png', caption?) | an image, and the caption as text |
toolError(message) | a failed result (isError: true) the model can read and react to |
a ToolResultPayload | exactly that: { content: [{ type: 'text', text } or { type: 'image', data, mimeType }], isError? }, 1–16 parts |
Throwing also returns an error to the agent, with the error’s message (and writes a warning to your card log). Results are ≤ 1 MB.
Timing
- Default timeout 30 s, or the tool’s
timeoutMs(up to 120 s). At the deadline, the app tells the agent the call timed out and sends your cardtools.cancel—call.signalaborts, and whatever you return afterwards is dropped. - Calls that arrive before your handler is registered (say, while the card is still loading its
data) wait for
handle()until their deadline. - A call to a card whose page is suspended wakes it (up to 10 s); if its workspace is not open the agent is told to open the workspace that has the card.
- Every call runs on the arrow: its label shows the tool and your progress lines, and it is kept in the arrow log.
Turning a tool on and off
await card.tools.setEnabled('run_tests', false) // hidden from agents (e.g. until the user signs in)
await card.tools.setEnabled('run_tests', true)This applies to every copy of your card.
With React
import { useTool } from '@neurosquad/card-sdk/react'
export function Scratchpad({ text }: { text: string }) {
useTool('read_scratchpad', () => text || '(empty)') // the latest `text` is always used
return <pre>{text}</pre>
}Tool results go straight into an agent’s context. Treat anything you return that came from the
web, a file or a user as untrusted: say where it came from, keep it short, and never let a tool
that the agent can call silently do something destructive — ask the user with card.ui.confirm
first.