Skip to content
GEOstack

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, or bun).
  • workspace A free Arc workspace. Sign up (Step 1) and point ARC_API_URL at https://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.

shell
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.

shell
# 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.tsts
// 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.tsts
// 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.tsts
// 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 · production (Redis)ts
// 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:

console · new budget
# 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:

src/agent.tsts
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:

console · audit
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

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 →