Protect an agent
The complete integration — configuration, handshake, decisions, refusals and approvals — in TypeScript and Python.
An agent is protected when every tool call it makes is decided by the gateway first. There are two ways to route calls there:
guard()wraps the function that performs a tool. This is whatveeforeadds to your code, and what most agents need.protect()wraps an agent object and gives itact,evaluate,simulateandhandshake, for code that calls Veefore directly.
The fastest route is to let the CLI do it:
bashnpm install -g veefore
cd your-agent-project
veefore
It writes the configuration below, registers the agent and its tools, and adds
guard(). Automatic setup covers each step. The rest
of this page is the same integration by hand.
#Configure
veefore init writes both files. You can also set them by hand.
veefore.config.json — not secret. Commit it.
json{
"agentId": "agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0",
"keyId": "key_8f2c1d4e5a6b7c8d",
"gatewayUrl": "https://gateway.veefore.example.com",
"gatewayAudience": "https://gateway.veefore.example.com",
"controlPlaneUrl": "https://veefore.example.com",
"organizationId": "org_01JAV…",
"workspaceId": "wsp_01JAV…",
"environmentId": "env_01JAV…"
}
.env.local — contains the private key. Never commit it.
ini# Veefore MANAGED BEGIN — do not edit between the markers
VEEFORE_GATEWAY_URL=https://gateway.veefore.example.com
VEEFORE_GATEWAY_AUDIENCE=https://gateway.veefore.example.com
VEEFORE_AGENT_ID=agt_01JAV7YQ4T2K9M3E8H6R5N1XZ0
VEEFORE_AGENT_KEY_ID=key_8f2c1d4e5a6b7c8d
VEEFORE_AGENT_PRIVATE_KEY=MC4CAQAwBQYDK2VwBCIEI…
VEEFORE_AGENT_NAME=finance-agent
# Veefore MANAGED END
The markers matter: re-running veefore init replaces only what is between
them and preserves your own variables.
The audience must match exactly
The gateway compares aud against its configured public URL as a string.
https://gateway.veefore.example.com and
https://gateway.veefore.example.com/ are different strings, and the mismatch
presents as a bare 401 with no explanation.
bashcurl -s https://gateway.veefore.example.com/.well-known/veefore-platform-key
Use the audience from that response. veefore init fetches it rather than
assuming.
| Variable | Required | Meaning |
|---|---|---|
VEEFORE_GATEWAY_URL |
yes | Where protected actions are sent |
VEEFORE_AGENT_ID |
yes | The identity |
VEEFORE_AGENT_PRIVATE_KEY |
yes | Base64 PKCS#8 Ed25519 |
VEEFORE_AGENT_KEY_ID |
yes | Which key, for rotation |
VEEFORE_GATEWAY_AUDIENCE |
recommended | Defaults to the gateway URL |
VEEFORE_AGENT_NAME |
no | Display metadata only |
VEEFORE_DECLARED_TOOLS |
no | Comma-separated, reported at handshake |
#Guard each tool
tsimport { guard } from '@veefore/sdk';
export const tools = {
refundCustomer: tool({
description: 'Refund a customer order',
parameters: z.object({ orderId: z.string(), amount: z.number() }),
execute: guard('refund_customer', async ({ orderId, amount }) =>
payments.refunds.create({ orderId, amount }),
),
}),
};
pythonfrom veefore import guard
@tool
@guard("refund_customer")
def refund_customer(order_id: str, amount: int) -> str:
"""Refund a customer order."""
return payments.refund(order_id, amount)
The key passed to guard() is the registered tool key. Each call is
authenticated, checked against capability, policy, risk and approval, and
recorded before your function runs. A refusal throws, and the agent framework
hands the error to the model like any other tool failure, so the model can tell
the user why.
If the tool is a brokered HTTP tool, the gateway performs the call with a
credential your code never holds and guard() returns that result without
running your function. See Register a tool.
#Wrap an agent object
tsimport { protect } from '@veefore/sdk';
const agent = protect(myAgent, {
name: 'finance-agent',
declaredTools: ['refund_customer', 'order_lookup'],
eagerHandshake: true,
onLog: (entry) => logger[entry.level]({ event: entry.event, ...entry.detail }, entry.message),
});
pythonfrom veefore import protect
agent = protect(
my_agent,
name="finance-agent",
declared_tools=["refund_customer", "order_lookup"],
eager_handshake=True,
)
Configuration comes from the environment, and anything you pass explicitly wins.
Missing required values raise VeeforeConfigurationError immediately, naming
what is absent — not three calls later with a confusing 401.
eagerHandshake opens the session at startup so misconfiguration surfaces then
rather than mid-task. declaredTools tells the gateway what you intend to call,
so anything you are not authorized for is reported at handshake.
onLog receives structured diagnostics and never receives secrets. Route it
into your logger; the SDK does not write to stdout on its own.
#What the server says you can do
tsconst info = await agent.handshake();
console.log(info.mode); // 'OBSERVE' | 'SIMULATE' | 'ENFORCE'
console.log(info.capabilities); // ['refund.create', 'order.read']
console.log(info.authorizedTools); // [{ key, name, capability, status }]
console.log(info.passportVersion); // 4
console.log(info.expiresAt); // session expiry
The handshake is advisory, not a cache
It tells you what the server currently thinks, which is useful for failing fast and for logging. It is not a permission grant and nothing in it is trusted on the next call — every action re-runs the full pipeline.
Do not implement "the handshake said I could, so skip the check". There is no check to skip; the gateway is the check.
#Act
Three methods, three different contracts.
| Method | On refusal |
|---|---|
act() |
Throws a typed error |
evaluate() |
Returns the decision as data |
simulate() |
Evaluates without executing — dryRun |
tsconst decision = await agent.act({
tool: 'refund_customer',
capability: 'refund.create',
action: 'create',
parameters: { orderId: 'ord_8812', reason: 'damaged on arrival' },
resource: { key: 'ord_8812', type: 'order' },
amount: 250_00,
currency: 'INR',
destination: 'payments.internal.example.com',
purpose: 'Customer reported damage; agent verified photo evidence.',
idempotencyKey: 'refund-ord_8812-attempt-1',
});
decision.allowed; // the only field to branch on
decision.decision; // ALLOW | BLOCK | REQUIRE_APPROVAL | STEP_UP_AUTH | LIMIT | MASK
decision.result; // the downstream response, when it executed
decision.reason; // human-readable
decision.explanation; // policy, version, rule, authority, risk
decision.actionId; // look it up later
pythonfrom veefore import ActionRequest
decision = agent.act(
ActionRequest(
tool="refund_customer",
capability="refund.create",
action="create",
parameters={"orderId": "ord_8812", "reason": "damaged on arrival"},
resource={"key": "ord_8812", "type": "order"},
amount=250_00,
currency="INR",
destination="payments.internal.example.com",
purpose="Customer reported damage; agent verified photo evidence.",
idempotency_key="refund-ord_8812-attempt-1",
)
)
decision.allowed
decision.decision
decision.result
decision.reason
decision.explanation
decision.action_id
purpose is worth filling in. It appears on the approval card, so an approver
reads why the agent wants to do this rather than inferring it from parameters.
An approver without context approves everything, which is worse than having no
approval step.
#Handle refusals
tsimport {
VeeforeApprovalRequiredError,
VeeforeApprovalDeniedError,
VeeforeBlockedError,
VeeforeAuthenticationError,
VeeforeRateLimitError,
VeeforeConnectionError,
} from '@veefore/sdk';
try {
const decision = await agent.act(request);
return { status: 'done', result: decision.result };
} catch (error) {
if (error instanceof VeeforeApprovalRequiredError) {
// Expected. Hand back to a human and resume later.
return { status: 'awaiting_approval', approvalId: error.approvalId };
}
if (error instanceof VeeforeApprovalDeniedError) {
// Expected. Report the reason verbatim.
return { status: 'denied', reason: error.message };
}
if (error instanceof VeeforeBlockedError) {
// Expected. Never retry a policy refusal.
return { status: 'refused', reason: error.message };
}
if (error instanceof VeeforeAuthenticationError) {
// Not expected. Identity is wrong — rotated, revoked, or bad audience.
throw error;
}
if (error instanceof VeeforeRateLimitError || error instanceof VeeforeConnectionError) {
// Transport. Retry with backoff. The SDK already retried.
throw error;
}
throw error;
}
pythonfrom veefore import (
VeeforeApprovalDeniedError,
VeeforeApprovalRequiredError,
VeeforeAuthenticationError,
VeeforeBlockedError,
VeeforeConnectionError,
VeeforeRateLimitError,
)
try:
decision = agent.act(request)
return {"status": "done", "result": decision.result}
except VeeforeApprovalRequiredError as error:
return {"status": "awaiting_approval", "approval_id": error.approval_id}
except VeeforeApprovalDeniedError as error:
return {"status": "denied", "reason": str(error)}
except VeeforeBlockedError as error:
return {"status": "refused", "reason": str(error)}
except VeeforeAuthenticationError:
raise
except (VeeforeRateLimitError, VeeforeConnectionError):
raise
The one rule: never retry a refusal. A 403 is the server's answer and the
answer will not change on the second call. The SDK encodes this — maxRetries
applies to transport errors only, never to decisions.
Full table.
#Approvals
ts// Block until a human decides, then proceed. Good for a background worker.
const decision = await agent.veefore.actWithApproval(request, {
timeoutMs: 15 * 60 * 1000,
});
Under the hood this evaluates, waits on the approval honouring pollAfterMs,
then resubmits the identical request with the approvalId. The gateway
re-validates everything — an approval is bound to one request and cannot be
widened or reused. See Handle approvals.
#Does it protect everything my agent does?
No, and it is important to be clear about which parts.
It protects every call that goes through guard() or act(). It does not
intercept arbitrary code, and it would be dishonest to imply otherwise.
veefore integrate --check lists every registered tool that is not guarded,
and exits 1 if any are not, so CI can hold that line.
What makes this robust is not the wrapper, it is that the tool's credential
lives in the broker. With a CLIENT tool your code still holds the key, so a
call that skips guard() still works. With a brokered HTTP tool, a call that
skips Veefore has nothing to call with.
If an agent currently holds a long-lived key for your API, moving it into the broker is the highest-value change you can make. Broker a credential.