Serving your own tools
This is the step that turns Pradra from an API you call into the AI backend of your product. You will expose one function from your codebase, let an agent call it, and read the trace of what happened.
Nothing about your service needs to be publicly reachable: your process opens an outbound connection and waits for work.
Step 1 — Create a worker pool
A pool is the name your worker processes connect to. It must exist first — a
worker that names a pool that does not exist gets a 404 and stops.
In the Studio: Tool workers → New pool. Give it a name that identifies
the service, e.g. shop-prod.
Or from code:
await pradra.pools.create("shop-prod", "The storefront backend");
Checkpoint: the pool appears in the list with None connected. That is the correct state — pools exist before workers.
Step 2 — Write the tool
A tool is a 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),
});
Three things to get right:
input is a field map, not a schema library. The platform's contract
vocabulary is a closed list — string, text, integer, number, boolean,
date, datetime, and a few more. Suffix a name with ? to make it optional:
{ order_id: "integer", note: "string?" }. Your handler's argument types are
inferred from it, so order_id is a number inside the body with no cast.
description is not decoration. It is what the model reads when deciding
whether this tool answers the question. "Look up an order's status, carrier and
ETA" gets chosen correctly; "order helper" does not.
sideEffect decides the control applied. Declare it honestly:
| Level | For | What the platform does |
|---|---|---|
read_only | Lookups | Callable freely once granted. Retried if a worker dies. |
reversible_write | Draft, tag, note | Requires the agent's explicit opt-in. Never retried. |
consequential | Refund, email, delete | Always parks for human approval. No setting disables it. Never retried. |
The class you write here is what stands between the model having a plausible idea and your customer receiving a refund nobody approved.
Step 3 — Run the worker
import { runWorker } from "@pradra/sdk";
const worker = runWorker({
baseUrl: process.env.PRADRA_URL!,
apiKey: process.env.PRADRA_API_KEY!,
orgId: process.env.PRADRA_ORG_ID!,
pool: "shop-prod",
tools: [getOrderStatus],
});
process.on("SIGTERM", () => worker.stop());
worker.stop() drains in-flight handlers instead of abandoning them. Wire
it up: a write abandoned halfway is precisely the case the platform refuses to
retry, so letting it finish is what keeps the work from being lost.
Checkpoint: the Studio's Tool workers page now shows your instance under the pool, with its SDK version, its tool names, and a heartbeat. The tool itself appears in Agent tools, marked managed by worker.
If the manifest is refused, the worker stops with the reason and does not retry. That is deliberate: a refused manifest is a bug in your deploy, and retrying it forever would hide the message that tells you what is wrong.
Step 4 — Grant the tool to an agent
Registering a tool exposes it to nobody. Each agent holds its own allowlist:
- Studio: the agent capability → Tools tab → grant it.
- Code:
await pradra.tools.grant(capabilityId, toolId).
This step is separate on purpose. A worker connecting can publish a tool definition, but can never widen what a running agent may call — otherwise a deploy could silently hand a live agent a refund tool.
Three more fences apply to every re-registration:
- Weakening a
sideEffectis refused. Turningconsequentialintoread_onlyfrom a manifest would remove the approval gate by editing your own code. - Dropping a declared
permissionis refused. - A tool missing from a later manifest is disabled, not deleted — its grants and history survive, so a bad deploy is reversible.
Step 5 — Run the agent
const run = await pradra.agents.run(capabilityId, {
goal: { objective: "Where is order 12345?" },
principal: { tenant_id: "store-a", user_id: "u_913" }, // from YOUR session
});
const finished = await pradra.agents.wait(run.id);
console.log(finished.status, finished.result);
principal never reaches the model and is never a tool argument — it arrives
in your handler as ctx.principal. Scope with it. See
the one rule.
Checkpoint: your handler ran (log something in it), and
GET /agent-runs/{id} — the trace page in the Studio — shows the plan, the
tool call, the observation and the final answer, in order.
Step 6 — Handle a parked approval
If your tool is consequential, the run stops and waits:
const run = await pradra.agents.wait(runId);
if (run.status === "awaiting_approval") {
// A human decides. wait() returns here rather than spinning: nothing will
// change until someone acts, and polling a decision is a lie.
const step = run.steps?.find((s) => s.status === "awaiting_approval");
// …surface step.tool and its arguments in YOUR product's approval UI…
await pradra.agents.approve(step!.id); // or .reject(step!.id, "reason")
}
On approval the exact parked call is re-checked from scratch — including the approver's permission — and then executed. Nothing about it can be edited in between: what was approved is what runs.
The Studio's Agent approvals queue does the same thing for operators.
Step 7 — Help the model find your tool
Every granted tool's name, description and arguments are in the prompt on every turn, so each run is offered a budgeted subset (five by default), chosen by deterministic keyword scoring. Steer it:
tool({
name: "refund_order",
routing: {
keywords: ["refund", "money back", "chargeback"],
patterns: ["\\bORD-\\d+\\b"], // shapes a keyword cannot express
},
// …
});
Being offered is not being allowed. Routing decides what is advertised; an unlisted tool is still callable by name and still passes exactly the same grant, permission and side-effect checks. Empty routing revokes nothing — the tool simply competes on its name.
Typo tolerance and prefix matching apply only to words of four characters or
more, so short abbreviations (po, sku, vat) stay exact.
Step 8 — Ground the assistant on the current screen
If the assistant sits on an order page, tell it which order:
runWorker({
/* … */
contexts: [{
contextType: "order_detail",
scope: "entity",
pinnedTools: ["refund_order"],
prefetchTool: "get_order", // must be read_only
prefetchArgs: { order_id: "$context_id" },
digestFields: [
{ path: "status", label: "Status" },
{ path: "eta", label: "ETA" },
],
}],
});
Now a turn carrying { context_type: "order_detail", context_id: "12345" }
answers "can I still cancel this?" without asking which order.
runWorker registers the descriptor; sending the context with a turn is a
REST field today (app_context on the run trigger) and has no TypeScript
helper yet. See the API reference.
The prefetch tool must be read_only: it fires unasked on every grounded
turn, so a write there would cause side effects nobody requested, and a
consequential one would park the run before the user finished typing.
Notice what you do 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.
About retries
If a worker dies mid-call, Pradra retries read_only tools only. A write
is never blindly retried: the platform cannot know whether it already landed
before your process disappeared, and "charge the card twice" is worse than
"report a failure".
A handler that reports an error is not retried either — that is your code saying no, not the transport failing:
handler: async ({ order_id }, ctx) => {
const order = await orders.find(order_id, ctx.principal.tenant_id);
if (!order) throw new Error("no such order for this customer");
return order;
},
Only the message crosses the wire, and the agent receives it as an observation — it can adapt. Your stack trace stays in your process.
Every call carries a stable id across retries (ctx.jobId). Use it as an
idempotency key for your own writes: Pradra cannot make your code exactly-once.
Next
- No long-lived process? Webhook tools.
- The concepts behind all of this: Your own tools.
- Give it memory: Conversations & streaming.