Your own tools
A tool is a function in your codebase that an agent may call. Not a rewritten service, not a copy of your data — usually a three-line wrapper around something you already have.
import { tool } from "@pradra/sdk";
export const getOrderStatus = tool({
name: "get_order_status",
description: "Look up an order's status, carrier and ETA.",
sideEffect: "read_only",
input: { order_id: "integer" },
handler: ({ order_id }, ctx) =>
orderService.findOne(order_id, ctx.principal.tenant_id),
});
input is a plain field map, not a schema library: the platform's contract
vocabulary is a closed list of types (string, integer, boolean, date,
…), so { order_id: "integer", note: "string?" } says everything there is to
say — and your handler's argument types are inferred from it.
Declare what it does to the world
The sideEffect you declare decides the control applied:
| Level | Meaning | Control |
|---|---|---|
read_only | Looks, never touches. | Callable freely once granted. |
reversible_write | Changes something undo-able. | Needs the capability's explicit opt-in. |
consequential | Refunds, emails, deletions. | Always parks for a human. No setting disables it. |
Declare honestly. The class you write here is what stands between a model having a plausible idea and your customer receiving a refund nobody approved.
Two transports, one tool
The same tool definition can be reached two ways. Switching is configuration — your handler does not change.
Worker (start here)
Your process opens an outbound connection to Pradra and waits for work:
import { runWorker } from "@pradra/sdk";
runWorker({
baseUrl, apiKey, orgId,
pool: "ecommerce-prod",
tools: [getOrderStatus, refundOrder],
});
Nothing about your service needs to be publicly reachable. It works from a laptop, from inside a private VPC, from behind NAT. Many replicas can serve one pool; a job is claimed by whichever polls first, never routed to a named instance.
Webhook
Pradra calls an HTTPS endpoint you expose:
import { mountTools } from "@pradra/sdk";
mountTools(app, { tools: [getOrderStatus], secret: process.env.PRADRA_TOOL_SECRET! });
Choose it when you already run a public API and prefer no long-lived outbound connection — for instance on a serverless platform where a polling process has nowhere to live.
Three things the platform insists on, and why:
- HTTPS only, and never a private, loopback or metadata address. The URL is checked when you save it and again against the address it actually resolves to at call time. Checking only at save time loses to a hostname that is re-pointed at an internal address a minute later, which turns your tool registration into a way to read Pradra's own network.
- A signing secret is mandatory. Every request is signed over the timestamp, the call id and the body. An unsigned webhook is an open door onto your business code for anyone who learns the URL, so it is not a configuration that is allowed to exist.
- Read the raw body when you verify. Re-serializing a parsed JSON body
changes the bytes and breaks the signature — the single most common cause of
a webhook that "randomly" rejects valid calls. The SDK handler does this for
you; if you write your own, do not run
express.json()first.
Requests carry a call id that is stable across retries. Use it as an idempotency key for your own writes: Pradra cannot make your code exactly-once.
Registration, and the four fences
A worker announces its tools on connect, so a new tool ships with your deploy instead of through a console form. Auto-registration is deliberately narrow:
- It writes definitions, never grants. A new worker can never widen what a running agent may call — otherwise a deploy could silently hand a live agent a refund tool.
- Weakening a side-effect class is refused. Turning
consequentialintoread_onlyfrom a manifest would remove the approval gate by editing your own code. - Dropping a declared permission is refused. Once a tool requires a permission, a later manifest cannot quietly stop requiring it.
- A tool missing from the manifest is disabled, not deleted. Its grants and history survive, so a bad deploy is reversible instead of destructive.
Tools registered this way are shown as managed by worker in the console and are read-only there: the manifest is the source of truth, and a console edit would be overwritten on your next deploy.
Retries: only for read_only
If a worker dies mid-call, Pradra retries read-only tools only. A write is never blindly retried, because the platform cannot know whether it already landed before the process disappeared — and "charge the card again" is a worse outcome than "report a failure". A handler that reports an error is not retried either: that is your code saying no, not the transport failing.
This is also why a graceful shutdown matters. worker.stop() drains in-flight
handlers rather than abandoning them; abandoning a write is precisely the case
nothing can safely re-run.
Which tools the model is offered
Every granted tool's name, description and arguments are part of the prompt on every turn, so a long catalogue costs tokens and makes the model's choice worse. Each run is therefore offered a budgeted subset, chosen by deterministic keyword scoring — free, reproducible, and explainable after the fact.
You can steer it with routing metadata:
tool({
name: "refund_order",
routing: { keywords: ["refund", "money back"], patterns: ["\\bORD-\\d+\\b"] },
// …
});
Being offered is not the same as being allowed. The budget decides what is
advertised; a tool that was not offered is still callable by name, and still
refused or allowed by exactly the same grant, permission and side-effect
checks. Leaving routing empty revokes nothing.
Grounding a screen
If the assistant is embedded in a screen — an order page, an invoice — the product can say which record is in view:
runWorker({
/* … */
contexts: [{
contextType: "order_detail",
scope: "entity",
pinnedTools: ["refund_order"],
prefetchTool: "get_order",
prefetchArgs: { order_id: "$context_id" },
digestFields: [{ path: "status", label: "Status" }, { path: "eta", label: "ETA" }],
}],
});
Then a turn sends { context_type: "order_detail", context_id: "12345" } and
"can I still cancel this?" resolves without the user restating which order they
mean. The prefetch runs through the same checks as any model-issued call, and
the prefetch tool must be read_only — it fires unasked on every grounded
turn, so a write there would cause side effects nobody requested.
Note what the product does not send: a label. Free text supplied by a client is text injected into a prompt; the assistant names the record from data a tool returned.
Next
- Who may call these, and how approvals work: Agent tools & approvals.
- Scoping your customers' data: Pradra as your product's AI backend.
- Conversations across turns: Conversations.
- Build one end to end: Serving your own tools.