Your agent can cancel an order at 3pm and not at 3am
A client-side tool runs in your application's process, which is exactly the right place for it — until there is no application process. Then the same agent, with the same permissions, on the same account, simply cannot do the thing.

This one takes a while to notice, because in development it never happens. You are always the connected client. You test in the chat window. The tool works.
Then you ship scheduled runs, or a webhook trigger, or subagents. All three execute server-side with nobody connected. A client tool is never offered to them, because there is nothing to hand the call back to. So an agent that could cancel an order in a chat window at 3pm cannot do it at 3am, and the failure is not an error — the tool simply is not in the catalog, so the agent plans around its absence and reports that it could not complete the task.
Three places a tool can live
| needs a connected client | knows which end-user | you host | |
|---|---|---|---|
| Client-side tool | yes | yes | nothing — runs in your app |
| MCP server | no | no — headers are static per project | a server |
| Webhook tool | no | yes, signed | an endpoint |
That middle row is the reason webhook tools exist as a separate thing rather than "just use MCP". An MCP server is configured per project, with static headers. It has no idea which of your end-users triggered the call. For a tool that reads public reference data that is fine. For a tool that cancels an order, the identity is the entire security model.
A webhook tool is an HTTPS endpoint you publish. Oberik calls it during the turn and hands your handler the end-user's identity, signed:
POST /agent/orders
X-Oberik-Signature: sha256=…
{
"tool": "cancel_order",
"arguments": { "order_id": "o_1029" }, // model-generated — validate it
"subject": "acme:fin:ana", // from the token, never the model
"scope": "acme:fin",
"roles": ["member"],
"tenant_id": "…",
"session_id": "…"
}subject is ours and is signed. Everything in arguments is text the model wrote. That distinction is the whole job of your handler:
const order = await orders.find(args.order_id);
if (order.customer !== subject) return res.status(403).json({ error: "not your order" });Two things about the response that are worth knowing
A non-2xx comes back to the agent as a result, not as a crash. Your 409 arrives as 'cancel_order' returned 409: order already shipped, and the agent can tell the user something true. A handler that explains its refusal is teaching the model something useful; one that returns a bare 500 has told it nothing except that the world is broken.
The body becomes the tool result, capped at 20,000 characters. Answer with JSON or text, whichever suits.
The header thing that will cost you an afternoon
There are two signing schemes, and I will save you the debugging session:
- A webhook tool delivery is signed with that tool's own secret, in
X-Oberik-Signature. - Everything Oberik POSTs at you for its own reasons —
turn.stopped,scheduled_task.fired— is signed with the project's webhook secret, inX-Signature.
Two schemes, two header names, two secrets. Wiring one verifier to the other's header fails every call and looks exactly like a broken signature, which sends you looking at your HMAC code, which is fine.
And: one secret per tool, not per project, returned once at creation and never listed again. Three tools means three secrets captured at creation time. If several tools point at one handler, key the lookup on the tool field in the body.
Rotation, and why nobody does it
Here is the reason webhook secrets never get rotated anywhere, in any product: rotating fails every call between issuing the new secret and finishing the deploy. So it is an outage you schedule on purpose, and it never reaches the top of anyone's list.
Rotating here returns a new secret and a previousSecretUntil. For 24 hours the signature header carries two comma-separated signatures, one per live secret. Accept the call if either matches:
const sigs = (req.header("X-Oberik-Signature") ?? "").split(",");
const ok = [process.env.SECRET_NEW, process.env.SECRET_OLD].filter(Boolean).some((s) =>
sigs.includes("sha256=" + createHmac("sha256", s!).update(raw).digest("hex")),
);Now rotating costs nothing and you deploy the new secret whenever it suits. Six lines of verifier, and a security practice stops being theoretical.
One thing this does not cover, since the assumption travels: rotating a webhook trigger's URL is a different operation and moves only the path. The signing secret is unchanged, and the response carries a new url and no secret. That rotate is for a leaked path — which is the credential when a trigger is unsigned. If the secret itself leaked, there is no rotate: delete the trigger and make a new one.
Watching them
A handler that starts returning 500 after your deploy is otherwise visible only inside one turn's tool result. The first sign is somebody asking why the agent stopped being able to do something, three days later.
curl -s "https://oberik.com/api/projects/$PROJECT_ID/webhook-tools/deliveries" \
-H "X-API-Key: $PROJECT_KEY"Which tool, what came back, how long it took, and the error if there was one. Check it after a deploy on your side.
Publishing is not granting
Defining a tool does not bind it. The webhook_tools capability decides whether a given token may reach any of them, so you can ship a tool that cancels an order and still mint a read-only token that cannot see it. allowed_tools names them individually, like any other tool.
Which is the same rule as everywhere else here: what exists and what a particular user may reach are two separate decisions, and collapsing them is how a capability becomes a suggestion.
