Install
Both SDKs have no dependencies: Python 3.10 or later with the standard library, or Node.js 20 or later. They implement the HTTP API, so anything they do can also be done with plain requests.
The toolcaise packages for PyPI and npm are prepared but not published yet. Until they are, download the SDK as one file and keep it beside your agent; it is the same code the packages will contain.
curl -fsSLO https://toolcaise.com/downloads/toolcaise_agent.py # then: from toolcaise_agent import Toolcaisecurl -fsSLO https://toolcaise.com/downloads/toolcaise-agent.mjs # then: import { Toolcaise } from "./toolcaise-agent.mjs"Then check that Toolcaise accepts the agent's credential:
export TOOLCAISE_AGENT_TOKEN=... # from Dashboard, Agents
python toolcaise_agent.py check # or: node toolcaise-agent.mjs checkConfigure
A client is one agent: the credential decides which workspace and which agent every report belongs to. Create one client per process and reuse it.
| Python | Node.js | Default | Meaning |
|---|---|---|---|
token | token | TOOLCAISE_AGENT_TOKEN | The agent credential, shown once when you create it. Keep it in the host's secret store; never ship it to a browser. |
url | url | TOOLCAISE_URL, else https://toolcaise.com | An HTTPS origin. Plain http is accepted only for localhost, 127.0.0.1 and [::1], so you can point an agent at a local development server. |
strict | strict | False | Send each report before returning and raise on failure, instead of queueing it. See below. |
timeout | timeoutMs | 10 s | How long one report request may take. Controls wait at least 15 seconds per attempt. |
max_queue | maxQueue | 1000 | How many reports are held while Toolcaise cannot be reached. New reports are dropped, with a warning, beyond it. |
exit_timeout | exitTimeoutMs | 2 s | How long the process waits at exit to deliver reports still queued. |
A missing credential does not stop your agent: the client warns once, reports nothing, and its checkpoints and guarded actions refuse to run. In strict mode it raises instead.
Report a run
Wrap each unit of work in a run. The run reports its start, its end (completed, failed, or canceled when a person cancelled it) and a heartbeat every 60 seconds. Inside it, spans time the steps; nested spans record their parent automatically; events and outcomes need no run ID. Exceptions are recorded as a failed status, never with their text, and are raised to your code unchanged.
from toolcaise_agent import Toolcaise
from openai import OpenAI
client = Toolcaise() # reads TOOLCAISE_AGENT_TOKEN
openai = client.instrument_openai(OpenAI()) # optional: record model calls
with client.run("nightly-research") as run_id:
with client.span("collect sources", kind="tool", tool="web_search"):
sources = collect_sources()
with client.span("write report"):
openai.chat.completions.create(model="gpt-4.1", messages=[...])
client.outcome("research report", status="succeeded")import { Toolcaise } from "./toolcaise-agent.mjs";
import OpenAI from "openai";
const client = new Toolcaise(); // reads TOOLCAISE_AGENT_TOKEN
const openai = client.instrumentOpenAI(new OpenAI()); // optional: record model calls
await client.run("nightly-research", async (runId) => {
const sources = await client.span("collect sources", () => collectSources(), {
kind: "tool",
tool: "web_search",
});
await client.span("write report", () =>
openai.chat.completions.create({ model: "gpt-4.1", messages: [/* ... */] }),
);
await client.outcome("research report", { status: "succeeded" });
});The current run follows your code: through async/await and asyncio tasks in Python (contextvars), and through promises, timers and callbacks in Node (AsyncLocalStorage). A plain Python thread does not inherit it; pass run_id= explicitly there, or start the thread with contextvars.copy_context().run. In async Python, use async with client.run(...) and async with client.span(...).
Values known only when a step finishes, such as token counts from a provider Toolcaise does not instrument, can be added to the open span with client.update_span(input_tokens=..., output_tokens=..., model=...) (client.updateSpan({ inputTokens, outputTokens, model })).
When Toolcaise is unreachable
Reporting is fail-open. Runs, spans, events, outcomes, heartbeats, metrics and output checks are queued in memory and delivered in the background (a thread in Python, the event loop in Node), so a slow or unreachable Toolcaise never raises into your code and never makes it wait. Consecutive reports travel together in one request when the server would treat them exactly as it would separately, which keeps a busy agent inside its 60 requests a minute.
| What happens | Default (fail-open) | strict=True |
|---|---|---|
| Network failure or timeout | Retried with exponential backoff up to 60 seconds; the same bytes are resent, so nothing is stored twice. One warning per outage. | Retried twice, then raises RuntimeError / rejects. |
| 429 | Held for exactly the Retry-After delay the server gives (60 seconds if it gives none). | Waits the delay, up to two retries, then raises. |
| 502, 503 or 504 | Treated like an outage: retried for as long as the queue has room, honouring Retry-After. | Retried twice, honouring Retry-After, then raises. |
| Any other 5xx | Retried five times with backoff, then dropped with a warning. | Raises. |
| 400 and other 4xx | A batch is split and resent one report at a time, so a single invalid report cannot sink the others; the invalid one is dropped with a warning that quotes the server's reason. | Raises with the server's reason. |
| 401 or 403 | Dropped with a warning to check TOOLCAISE_AGENT_TOKEN. | Raises. |
| Queue full | New reports are dropped with a warning, at most one a minute, with a running count. | Not applicable: nothing is queued. |
| Process exit | Waits up to exit_timeout for queued reports, then reports how many were not delivered. | Nothing is queued. |
| A report that is not JSON or is over 128 KB | Dropped with a warning. | Raises ValueError / rejects. |
Warnings go to the toolcaise logger in Python and are process warnings of type ToolcaiseWarning in Node (visible on stderr unless you run with --no-warnings). client.delivery_stats() (deliveryStats()) returns how many reports are queued, sent and dropped, and client.flush(timeout) (await client.flush({ timeoutMs })) waits for the queue to drain.
In Node, queued reports are delivered when the event loop empties (the beforeExit event). process.exit() and crashes skip that, so call await client.flush() first. A report accepted into the queue is not yet stored: it can still be dropped if Toolcaise stays unreachable past the exit wait or the queue fills.
Controls are fail-closed in every mode. checkpoint() and guarded_action() talk to Toolcaise directly and raise (reject) if they cannot confirm the decision, so an unapproved action never runs because Toolcaise was down. Before asking, they wait up to one request timeout for queued reports, because Toolcaise refuses controls for a run it has not heard of.
Names in Python and Node
The two SDKs have the same methods and options, each spelled in its language's style: Python uses snake_case and seconds, Node uses camelCase and milliseconds with an Ms suffix. Positional arguments are the same; Python takes the rest as keywords and Node as a final options object.
| Concept | Python | Node.js |
|---|---|---|
| Run ID | run_id | runId |
| Parent span | parent_span_id (parent_id still accepted) | parentSpanId |
| Span token counts and cost | input_tokens, output_tokens, reported_cost_usd (the camelCase spellings still work) | inputTokens, outputTokens, reportedCostUsd |
| Stable IDs for retries | event_id, outcome_id, sample_id, check_id, call_id | eventId, outcomeId, sampleId, checkId, callId |
| Durations | timeout, poll_seconds, exit_timeout, max_age_seconds (seconds) | timeoutMs, pollMs, exitTimeoutMs; maxAgeSeconds is in seconds, as its name says |
| Metric names in values | The API's names, unchanged: inputTokens, reportedCostUsd, ... | The same |
Method reference
Signatures are Python first, then Node. Methods marked reporting are fail-open (they return None / resolve null once queued, or the server's answer in strict mode); controls always raise on failure.
Runs and steps
| Method | What it does |
|---|---|
run(label, run_id=None)run(label, execute, { runId }) | Context manager (sync or async) yielding the run ID in Python; in Node, calls execute(runId) and resolves with its result. Reporting. |
span(name, kind="step", tool=, model=, input_tokens=, ...)span(name, execute, { kind, tool, model, inputTokens, ... }) | Times one step inside the current run. kind is step, tool, model or retry. Outside a run it warns and records nothing (strict mode raises). The form span(run_id, name) from before 1.0 still works. Reporting. |
update_span(**fields)updateSpan(fields) | Adds model, tool, token counts, cost or metadata to the innermost open span. Returns false when no span is open. |
current_run_id() (module function)currentRunId() (named export) | The run this code is inside, or None / null. |
Reports
| Method | What it does |
|---|---|
event(event_type, message, level="info", run_id=, event_id=)event(eventType, message, { level, runId, eventId }) | An event, attached to the current run unless you pass another run ID (in Node, runId: null records an agent-level event). level is info, warning or error. Reporting. |
outcome(label, status="unknown", artifact_digest=, outcome_id=)outcome(label, { status, artifactDigest, outcomeId }) | What the run produced, with a reported status of succeeded, failed or unknown and optionally the SHA-256 of the artifact. A person reviews it separately. The form outcome(run_id, label, ...) still works. Reporting. |
heartbeat(status="healthy") | Reports the agent as healthy, degraded, offline or disabled. run() sends one every 60 seconds. Reporting. |
report(**fields) / report(fields) | A raw telemetry report, as described in the API reference. Reporting. |
supervision(**fields) / supervision(fields) | A supervision workflow operation such as creating a recovery step or claiming an authorized recovery. Request and response, never queued; raises on failure in every mode, and a recovery claim is never retried. |
claim_recovery(item_id, expected_version, action_digest, idempotency_key=)claimRecovery({ id, expectedVersion, actionDigest, idempotencyKey })report_recovery_result(claim, succeeded) / reportRecoveryResult(claim, { succeeded }) | Claims a recovery step before you run it, with an idempotency key: a lost answer is retried with the same key and returns the same claim, never a second one. Run the step only when it returns; then report the result against the claim. A write, or a step whose effect was not reported, needs a person's authorization first. |
Delivery
| Method | What it does |
|---|---|
flush(timeout=5.0)flush({ timeoutMs: 5000 }) | Waits for queued reports to be delivered. True when none remain. |
delivery_stats() / deliveryStats() | Counts of queued, sent and dropped reports. |
Controls, instrumentation and measurements are described in their own sections below.
Controls and approvals
A person can pause, resume or cancel an agent or a single run from the dashboard, and approve or deny actions you guard. The agent applies these at checkpoints you choose, so nothing interrupts a step already in progress.
| Method | What it does |
|---|---|
checkpoint(run_id=None, poll_seconds=3, timeout=3600)checkpoint({ pollMs, timeoutMs }) | Waits while the agent or run is paused and raises AgentCancelled if it was cancelled. Call it between safe steps. Control. |
guarded_action(title, action, execute, timeout=3600, reason=None, destination=None, affected_count=None, show_fields=None)guardedAction(title, action, execute, { timeoutMs, reason, destination, affectedCount, showFields }) | Asks a person to approve the exact action, waits, uses the approval once, and only then calls execute with a frozen copy of the action. Denial, expiry, a mismatch or any uncertainty raises and nothing runs. The other options say what the person deciding sees; see below. Control. |
acheckpoint(...), aguarded_action(...) | Python only: the same for async code, waiting in a worker thread instead of blocking the event loop. execute may be a coroutine function. |
with client.run("weekly-digest"):
client.checkpoint() # waits here while a person has paused the agent
client.guarded_action(
"Send the weekly digest",
{"operation": "send_digest", "list": "subscribers", "draft": draft_id},
lambda approved: send_digest(approved["list"], approved["draft"]),
reason="Sends to more than 1,000 people need a person",
destination="Newsletter: subscribers",
affected_count=1240,
show_fields=["list", "draft"],
)await client.run("weekly-digest", async () => {
await client.checkpoint();
await client.guardedAction(
"Send the weekly digest",
{ operation: "send_digest", list: "subscribers", draft: draftId },
(approved) => sendDigest(approved.list, approved.draft),
{
reason: "Sends to more than 1,000 people need a person",
destination: "Newsletter: subscribers",
affectedCount: 1240,
showFields: ["list", "draft"],
},
);
});Put every parameter that matters in the action and use the snapshot you are given: Toolcaise approves exactly the SHA-256 digest of that action. The forms checkpoint(run_id) and guarded_action(run_id, title, action, execute) from before 1.0 still work.
What the person deciding sees
The title, and anything you choose to add. The rest of the action never leaves your process.
| Option | What it shows |
|---|---|
reason | Why a person must approve it, for example the policy rule that matched. Up to 200 characters. |
destination | What the action writes to: a system, host or record type. Up to 120 characters. |
affected_count / affectedCount | How many records, messages or people it affects. A whole number. |
show_fields / showFields | Up to 12 fields of the action, by name or dotted path ("customer.id", "items.0.sku"). Their values are taken from the snapshot being approved; objects and lists show as compact JSON. Each value is cut to 200 characters. |
Before anything is sent, the SDK replaces credential-like strings (bearer tokens, API keys, private keys, password= pairs) and email addresses with [redacted] in the title and in everything above, and shows [redacted] for any field whose name suggests a secret (password, token, credential, cookie, or a name ending in key, such as api_key or sshKey), at any depth. Toolcaise applies the same filter again when the request arrives. The dashboard shows these details as reported by the agent: Toolcaise cannot check them against the digest, which is what the approval is bound to. These options need SDK 1.1.0 or later: an older Python SDK refuses them with a TypeError, and an older Node.js SDK ignores them.
When the agent's last decided request with the same title and destination, made in the 30 days before, had a different digest, the dashboard says the parameters changed since that decision and shows which of the shared fields differ. So keep the title the same each time the agent asks for the same action, and show what can change as fields. When the same digest was approved in those 30 days, it says so and links to it. Whoever decides can leave a note, which is kept with the decision and never sent to the agent. Alert destinations that take warnings (email, Slack, Teams, Discord and webhooks, never PagerDuty) are told when a request arrives.
Model-call instrumentation
instrument_openai(client) and instrument_anthropic(client) (instrumentOpenAI, instrumentAnthropic) wrap an OpenAI or Anthropic client you created and return it. Each call to chat.completions.create, responses.create or messages.create made inside a run is then recorded as a model span under the current span, plus a call-scope usage sample, with:
- the model the provider answered with (else the one requested);
- input and output tokens from the provider's usage fields (for Anthropic, input includes cache reads and writes);
- latency, whether it streamed, and success or failure.
Prompts, messages, output, tool arguments and error text are never read. Sync, async and streamed calls are covered: a stream is recorded when you finish iterating it, and its token counts come from its final events. OpenAI chat streams report usage only when you pass stream_options={"include_usage": True}; without it the span has no token counts rather than zeros.
| Case | Behaviour |
|---|---|
| Call outside a run | Not recorded; the call is untouched. |
| Python with_raw_response / with_streaming_response | Recorded when you call parse(); if the response is closed unparsed, recorded without token counts. |
| Node .withResponse(), .catch(), .finally() | Recorded. .asResponse() is not: the body stays yours to read and is never read early. |
| Other methods (for example beta.chat.completions.parse, messages.stream, embeddings) | Not instrumented. Use a span with update_span for them. |
| Recording fails | Logged as a warning; the model call's result or exception is returned unchanged, in strict mode too. |
Token counts are what the provider reported to your process, stored as agent-reported. For measurements an agent cannot alter, and for limits that stop a run, run the agent under Toolcaise Connect.
Usage, resources and output checks
| Method | What it does |
|---|---|
metrics(values, run_id=, scope="host", call_id=, tags=, sample_id=)metrics(values, { runId, scope, callId, tags, sampleId }) | A measurement sample. scope is host, run (cumulative totals for a run) or call (one model call, with a stable callId). Run and call samples default to the current run. null means unknown. Reporting. |
sample_resources(disk_path=, output_path=, artifact_path=, workload_items=)sampleResources({ diskPath, outputPath, artifactPath }) | Reads this process's memory and CPU and local disk and file sizes, and returns them for metrics(). Sends nothing; never reads file contents. |
output_checks(path, name, max_age_seconds=, schema=)outputChecks(path, name, { maxAgeSeconds, schema }) | Checks a file on this host exists, is not empty, is fresh and matches a small JSON Schema subset, and reports only the results: never the path or the contents. Reporting. |
client.metrics({"contextTokens": 18000, "contextCapacity": 128000}, scope="call", call_id="summarize-1")
client.metrics(client.sample_resources(output_path="report.json"))
client.output_checks("report.json", "Daily report", max_age_seconds=3600,
schema={"type": "object", "required": ["summary"]})The accepted metric names and bounds are in the API reference.
Upgrading from the single file
Version 1.0 replaces the downloadable single-file SDK and keeps every method name and call form it had. What changes:
- Reporting no longer raises or waits.
report(),heartbeat(),event(),outcome(),metrics()andoutput_checks()returnNone(resolvenull) once queued, instead of the server's answer.run()starts your code even when the start could not be reported, and a failed heartbeat no longer raises after a successful run. Passstrict=Truefor the previous behaviour. - Inside a run,
event()now attaches to that run when no run ID is given. - A 429 waits for the server's Retry-After instead of a fixed 60 seconds, in every mode.
checkis unchanged: it sends one heartbeat and fails loudly.
What is sent
Only operational metadata: run and span names, statuses, timings, model names, token counts and costs, event types and the messages you write, outcome labels, measurement values and check results. Never prompts, model output, file contents, exception text or tool arguments; guarded actions send a digest of the action and only the fields you choose to show, redacted. Labels are cut to their limits and credential-like strings are redacted, but free text you write is stored as written, so keep secrets and personal data out of names, titles and messages.
Everything an agent reports is stored and shown as agent-reported: a claim, not independent proof.