Pradra StudioDocs

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:

LevelMeaningControl
read_onlyLooks, never touches.Callable freely once granted.
reversible_writeChanges something undo-able.Needs the capability's explicit opt-in.
consequentialRefunds, 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:

  1. 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.
  2. Weakening a side-effect class is refused. Turning consequential into read_only from a manifest would remove the approval gate by editing your own code.
  3. Dropping a declared permission is refused. Once a tool requires a permission, a later manifest cannot quietly stop requiring it.
  4. 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