Quickstart · 5 min
See Arc block an over-budget agent action in five minutes.
You'll install @geostack/arc, define an action that carries a cost,
wrap a dangerous call so it routes through an allow / ask / block policy, verify
the signed execution request in your own app, set a cumulative spend cap, and watch
Arc refuse the action that would blow the budget. That last step is your
time-to-first-block — the moment the guardrail is real.
What you need
- runtime Node 20+ and a package manager (
npm,pnpm, orbun). - workspace A free Arc workspace. Sign up (Step 1) and point
ARC_API_URLathttps://app.geostack.xyz. No credit card. - time ~5 minutes. No production credentials required.
Sign up and install the SDK
Arc is hosted — sign up for a free workspace (no credit card),
then install the @geostack/arc SDK in your project.
npm install @geostack/arc Create your app and mint an agent token
In the console (app.geostack.xyz), create an app from your risky
actions, set its execute_url (Step 4),
and mint an agent token. That token — with your app ID and the API URL
https://app.geostack.xyz — is all the rest of the quickstart needs.
# Copied from the console after you create your app: export ARC_API_URL="https://app.geostack.xyz" export ARC_APP_ID="app_..." export ARC_AGENT_TOKEN="arc_agent_..."
The agent token is the only credential your agent holds. It can ask Arc to act, but it never carries your app's own API keys — Arc delivers approved work to your app as a signed request your app verifies (Step 4).
Define an action — and give it a cost
An action is one thing your agent can ask to do. Each declares a
risk level and a defaultDecision of
allow, ask, or block. Define them once with
arc.defineActions and you get a typed map you reuse on both the policy
side and the handler side.
read_invoicelow allow issue_refundhigh ask delete_accountcritical block // src/actions.ts
import { arc } from "@geostack/arc";
export const actions = arc.defineActions({
read_invoice: {
name: "Read invoice",
description: "Read an invoice. Safe, read-only.",
risk: "low",
defaultDecision: "allow",
input: {
type: "object",
additionalProperties: false,
required: ["invoiceId"],
properties: { invoiceId: { type: "string" } }
}
},
issue_refund: {
name: "Issue refund",
description: "Move real money back to a customer.",
risk: "high",
defaultDecision: "ask", // pause for a human on every refund
// Charge the `amount` input (in dollars) against any matching budget.
cost: { mode: "field", field: "amount", currency: "USD" },
input: {
type: "object",
additionalProperties: false,
required: ["customerId", "amount"],
properties: {
customerId: { type: "string" },
amount: { type: "number" }
}
}
},
delete_account: {
name: "Delete account",
description: "Irreversible. This policy blocks it outright.",
risk: "critical",
defaultDecision: "block", // never, for any agent
input: {
type: "object",
additionalProperties: false,
required: ["customerId"],
properties: { customerId: { type: "string" } }
}
}
}); defineActions is the typed contract your
agent and handler share in code. Register the matching actions — each with its risk and a default
allow / ask / block policy —
in the console when you create your app (Step 1). That policy is what the hosted control plane enforces.
Wrap the dangerous call allow ask block
Your agent never touches production credentials directly. It asks Arc, and Arc decides.
Drive it through the runtime client and branch on the returned status:
// src/agent.ts
import { createArcAgentRuntime } from "@geostack/arc";
const arcRuntime = createArcAgentRuntime({
apiUrl: "https://app.geostack.xyz",
agentToken: process.env.ARC_AGENT_TOKEN! // minted in the console (Step 1)
});
const APP_ID = process.env.ARC_APP_ID!;
async function refund(customerId: string, amount: number) {
const result = await arcRuntime.invoke(APP_ID, "issue_refund", {
customerId,
amount
});
switch (result.status) {
case "executed":
// allow -> Arc signed it, your app ran it, here is the result
return result.result;
case "queued":
// allow -> accepted, signed delivery to your app is in flight
return { pending: true, invocationId: result.invocation_id };
case "pending_approval":
// ask -> a human must approve before anything runs
return { needsApproval: true, approvalId: result.approval_id };
case "blocked":
// block -> policy or budget refused it; nothing executed
throw new Error("Arc blocked this refund.");
case "error":
throw new Error(`Arc error: ${result.error.code}`);
}
} invoke() returns a typed, discriminated result. You never guess:
allow becomes executed or queued,
ask becomes pending_approval, and block becomes
blocked with nothing executed. Every call carries an idempotency key
automatically (pass { idempotencyKey } to set your own).
Pass the agent token you minted in the console (Step 1) as
ARC_AGENT_TOKEN. Each
invoke() returns one of the statuses above:
read_invoice executes,
issue_refund waits for approval, and
delete_account is blocked. Pending approvals
appear in the console — approve or deny them there, and the held action runs (or doesn't).
Receive signed execution in your app signed
When Arc allows (or a human approves) an action, it doesn't call your business logic
blindly. It sends a signed (ES256) execution request to your
execute_url. Your app verifies the signature, the body hash, the timestamp,
and a one-time nonce before doing anything real.
handleAction does all of that, then dispatches to the matching handler:
// src/server.ts
import express from "express";
import { arc } from "@geostack/arc";
import { actions } from "./actions.js";
import { nonceStore } from "./nonce-store.js";
const app = express();
app.use(express.json());
app.post("/arc/execute", arc.handleAction(
actions,
{
// Only allowed/approved actions ever reach a handler.
read_invoice: async ({ input }) => getInvoice(input.invoiceId),
issue_refund: async ({ input, invocationId }) => {
// Treat invocationId as an idempotency key: Arc may retry delivery.
if (await alreadyRefunded(invocationId)) {
return getStoredRefund(invocationId);
}
return issueRefund(input.customerId, input.amount, invocationId);
}
// delete_account has no handler: blocked by policy, never delivered.
},
{
apiUrl: "https://app.geostack.xyz",
nonceStore // durable in production — see below
}
));
app.listen(8787); handleAction verifies Arc's ES256 JWS against the server's JWKS (fetched
from /.well-known/jwks.json), confirms the body hash matches the signed
claims, checks timestamp freshness, and runs the nonce through your store —
all before your handler executes. A failed signature returns
401; a bad request returns 400; your handler only runs on a
verified, single-use request.
The nonce store — durable in production
The nonce store is what stops a captured signed request from being replayed. The
contract is one method — return false if the nonce was seen before.
// src/nonce-store.ts
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
export const nonceStore = {
async useNonce(nonce: string, expiresAt: Date) {
// Atomic insert-if-absent + TTL. "OK" = first time we've seen it.
const result = await redis.set(`arc:nonce:${nonce}`, "1", {
NX: true,
PXAT: expiresAt.getTime()
});
return result === "OK";
}
};
For local dev only, createMemoryNonceStore() from @geostack/arc
works in-process — but it's lost on restart and never shared across instances.
Set a budget — the cap that didn't exist
A budget caps cumulative spend over a window. Arc charges every costed
action against matching budgets before it lets the action proceed. When the
projected spend would exceed a hard cap, Arc flips the decision to
block — no matter that the individual refund was within policy.
Create a budget in the console (Budgets → New). This one caps refunds at $100 per rolling hour, org-wide, and hard-blocks on breach:
# Budget
name Refund cap (hourly)
limit $100.00 # cumulative cap over the window
currency USD
window rolling · 3600s # per rolling hour
on breach block # hard cap. Use "ask" for a soft cap -> approval Scope a budget to a single agent, app, or action; leave it unscoped for an org-wide cap on every costed action.
Watch Arc block the over-budget action block
You set a $100/hour refund cap. Run two refunds totalling $110 inside one rolling hour: the first ($60) is within budget and routed for approval — approve it in the console — and the second ($50) crosses the cap.
From code, the second call comes back as a clean, typed block:
const result = await arcRuntime.invoke(APP_ID, "issue_refund", {
customerId: "cus_2",
amount: 50
});
result.status; // "blocked"
result.decision; // "block"
result.result; // null — nothing executed, no money moved Confirm it in the console's audit log — the block is a first-class, redacted, hash-chained event:
event_type: "budget_exceeded" reason: "budget_exceeded" budget_name: "Refund cap (hourly)" limit_minor: 10000 committed_minor: 6000
That's your first block. The runaway action was stopped before it touched a credential, and the refusal is recorded as tamper-evident evidence. The cap exists now.
What you just built
The trust envelope, end to end.
- allow ask block
Policy
Every action passes an allow / ask / block decision.
defineActions. - ask
Approval
Risky actions pause for a human.
ask → pending_approval. - signed
Signed execution
Your app verifies an ES256 request with a durable nonce before any side effect.
handleAction. - block
Spend guardrail
Cumulative cost is charged against budgets; over-budget actions are blocked or escalated.
- audit
Audit
Every decision, including the block, is a redacted, hash-chained record you can re-verify in the console.
Next steps
- Core conceptsEvery Arc term defined: actions, policy, approvals, signed execution, audit, budgets.
- Spend & budgetsCost modes, rolling windows, scoping, hard vs soft caps — Step 5.
- Verify signed executionhandleAction, body hash, nonce + idempotency — Step 4.
- The full product walk-throughEvery stage of one action, from request to audit.
- MCP adapterPut the same guardrails in front of any MCP server.
This quickstart runs against your hosted Arc workspace at https://app.geostack.xyz.
Before you put it in production, harden your side: a durable shared nonce
store, idempotency by invocation_id, observability, and egress controls.
See the security model →