Skip to content
adeia fence
Sign in
Quickstart

Run it
yourself

Eight commands from a fresh clone. At the end you have a server on localhost holding a policy your agent cannot edit, an invoice for $25.00 that settles itself into the ledger without asking anyone, and one for $500.00 sitting in your inbox waiting for you to say yes — with every step of both on record.

Setup

Nothing to a first action

Run these from the repository root, in order. Each step says what it prints and what to confirm before you move on — most of the ways this goes wrong are silent otherwise, and surface an hour later as an approval email nobody ever received.

01

Install

Node 22 or newer. The repository is a set of npm workspaces, so one install at the root covers the server, the SDK and the demo agent.

run
$ node --version
$ npm install
Check. The version prints v22 or higher. better-sqlite3 is a native module and may need a rebuild on a very new Node — if the install or the first boot complains about it, run npm rebuild better-sqlite3 now rather than five minutes before you need the server.
02

Write the environment file

The example carries every key with a comment naming what needs it. Copy it to the repository root — that is where the server looks — and fill in the approval block: a mailbox to send from, and an address to send to.

run, then edit .env
$ cp config/.env.example .env

SMTP_USER=you@gmail.com
SMTP_PASSWORD=xxxxxxxxxxxxxxxx
APPROVAL_FROM_EMAIL=you@gmail.com
APPROVER_EMAIL=you@gmail.com
Check. SMTP_PASSWORD is a sixteen-character app password, not your account password — Gmail rejects the account password over SMTP, and it does not belong in a file on disk regardless. Turn on 2-Step Verification first, then create one. Set both SMTP variables or neither: half a pair is a startup error, never a quiet switch to another transport.
03

Open a tunnel

The approval link is opened on a phone, away from your machine, so the base URL in the email has to be reachable from outside it. A localhost link is useless to the person holding the device.

run, then paste the https URL into .env
$ ngrok http 3000

PUBLIC_BASE_URL=https://your-tunnel.ngrok-free.app
Check. The URL changes every time ngrok restarts. Re-set it and restart the server, or every emailed link points at a dead host. Any well-formed URL passes validation, including the placeholder — the only real check is reading the approvals line the server prints in step 5 and confirming it names your live tunnel.
04

Seed a project and a policy

One project, one payment policy, one API key. This is where the fence gets its numbers: a $50.00 per-action limit, a $1,000.00 hard ceiling and a $2,000.00 daily cap.

npm run seed
  project   proj_lxvktwnj3wfu1vki0cem3  (demo)
  database  ./adeia.db
  policy    pol_9rk4h2vqx8m1td7nzs0ec  type=payment
    per-action limit   $50.00     above this → approval
    hard maximum       $1000.00   above this → denied
    daily cap          $2000.00   per currency, UTC day
    allowed recipients any

  API key — copy it now, it is not stored and will not be shown again:
    adeia_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
then, in the shell you will use
$ export ADEIA_API_KEY=adeia_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
$ export ADEIA_URL=http://localhost:3000
Check. Copy the key before you clear the terminal. Only its sha256 hash is stored, so it genuinely cannot be shown again.

Or skip this step. Sign in with GitHub and Adeia creates the project, both policies and a key for you, and shows the key once. The dashboard is also where you replace a key you have lost — the seed script has no way to do that, because it only ever knew the hash either.

A project made that way starts with an empty host allowlist, which refuses every outbound call until you add a host. That is deliberate — guessing which hosts you trust is not something to do on your behalf — and the dashboard has a field for it.

05

Start the server

The server logs in to the mailbox before it listens. If the app password is wrong you find out here, in a startup error, rather than an hour later through a payment that paused correctly and reached nobody.

npm run dev
[adeia] listening on http://localhost:3000  (db: ./adeia.db)
[adeia] adapters: ledger, http
[adeia] NO PAYMENT PROCESSOR ATTACHED — payments are authorised and
[adeia]   recorded; no money moves.
[adeia] approvals: you@gmail.com via https://your-tunnel.ngrok-free.app
[adeia]   sending as you@gmail.com over smtp smtp.gmail.com:465
Check. The approvals line must name your live tunnel and the address you want the mail at. In another terminal, curl localhost:3000/healthz answers {"ok":true}. If the server refuses to start it is telling you which variable is missing — it does that on purpose rather than run half configured.
06

Ask for a payment by hand

Before wiring up a model, prove the path with curl. The repository ships the request bodies used in the docs, so there is nothing to type out.

run
$ curl -X POST localhost:3000/v1/actions \
    -H "authorization: Bearer $ADEIA_API_KEY" \
    -H "content-type: application/json" \
    -d @docs/json/examples/request-under-limit.json
prints — HTTP 201
{
  "id": "act_8xk2m4pq7vn3jd6wztc0b",
  "status": "executed",
  "decision": "allow",
  "decisionReason": "within policy",
  "result": {
    "ledgerEntryId": "led_act_8xk2m4pq7vn3jd6wztc0b",
    "status": "recorded",
    "settled": false
  }
}
Check. Send the identical body a second time and you get the same id back, with no second ledger entry — the idempotency key in the file is unique per project and the duplicate is caught before any adapter runs.
07

Hand it to an agent

The demo agent gets two invoices and exactly one tool. It holds no payment credentials and has nothing that reaches past Adeia. Add ANTHROPIC_API_KEY to .env first — the server never calls a model for the fence itself, only this agent does.

npm run demo
→ requesting $25.00 to acct_cloudhost — monthly hosting
   executed: within policy

→ requesting $500.00 to acct_contractor — Q3 design work
⏸  $500.00 to acct_contractor needs human approval.
   amount 50000 exceeds per-action limit 5000
   Approval email sent. Waiting…
Check. Open the emailed link and approve. The agent resumes and reports both invoices. It waits five minutes for a decision and then reports the payment as still pending rather than guessing — a timeout is not an approval.
08

Read the trail

Every state change wrote an event as it happened. Pass the id of the $500.00 action to see the whole sequence, including who decided it and when.

run
$ npm run audit -- act_q7w2e9r4t1y6u3i8o5p0a
Check. Two executed actions at the end of a full run — not one, not three: sqlite3 adeia.db "SELECT id, status FROM actions;". The same trail is available over HTTP at GET /v1/actions/:id/audit.
Environment

What each variable is holding up

Read from a .env at the repository root, validated once at boot. Nothing here is read lazily, so a missing value is a startup error and not a surprise on the first request that needs it.

The server refuses to start without these

Booting without somewhere to send approval requests produces the worst available failure: over-limit actions pause exactly as they should and then wait forever, because nothing ever tells a human they were asked. From the outside that is indistinguishable from a hung agent, so the server will not do it.

SMTP_USER required
Together with SMTP_PASSWORD, one of the two transports — the mailbox the approval mail is sent through. Set one of the pair and not the other and the server stops with an error naming the missing half. It will not fall back to Resend: the channel a human is watching must not change because of a typo.
SMTP_PASSWORD required
An app password, never an account password. Verified with a real login before the server listens, so a wrong one is a startup failure rather than a payment that pauses correctly and reaches nobody.
RESEND_API_KEY alternative
The other transport, used only when the SMTP pair is entirely absent. It needs an account and a verified sender domain, and that verification is not instant — set it up well before you need it.
APPROVAL_FROM_EMAIL required
The address the approval mail is sent from. On Gmail SMTP it must be the SMTP_USER mailbox or one of its verified aliases; Gmail silently rewrites anything else, and the mail arrives from an address you did not choose.
APPROVER_EMAIL required
Where approval requests land. One approver per deployment for now — per-project approvers would need a column on the projects table.
PUBLIC_BASE_URL required
The origin the approval link is built on, with trailing slashes stripped. It must be publicly reachable. This is the one required variable whose validation cannot save you: any well-formed URL passes, so a stale tunnel address boots cleanly and mails links to a host that no longer exists.

These turn features on when you set them

All optional. Leave them out and the server runs exactly as before, minus the feature — never with the feature quietly half-on.

ANTHROPIC_API_KEY classifier
Powers the risk classifier. Without it the server runs a stub that refuses every classification and sends the action to a person — so a policy that opened methods to a classifier that was never configured produces approval emails rather than unattended writes. A missing key must not be a quiet upgrade in what an agent may do on its own. The demo agent reads the same variable.
GITHUB_CLIENT_ID
From the GitHub OAuth app. Public; it travels in the redirect URL.
GITHUB_CLIENT_SECRET
Secret. Never logged, never rendered, never leaves the token exchange.
GITHUB_REDIRECT_URI
Must match the callback registered on the OAuth app exactly, including scheme and port. Deliberately its own variable rather than derived from PUBLIC_BASE_URL: that one points at a tunnel whose hostname changes every restart, and a moving callback is a login that breaks every morning. All three are optional together — without them the dashboard serves a page explaining how to configure it rather than a broken login button.

These have defaults worth knowing

Leave every one of them unset and the server still starts and still behaves correctly. They are here because the defaults are the ones you will want to change first.

PORT 3000
Change it and your tunnel command changes with it, or the approval links point at nothing.
ADEIA_DB_PATH ./adeia.db
The SQLite file. Delete it between rehearsals for a clean trail; tests use an in-memory database instead.
NODE_ENV development
One of development, test or production. Anything else fails validation rather than being treated as unset.
SMTP_HOST smtp.gmail.com
Only read when the SMTP pair is set. Any mailbox that speaks SMTP works; the default is just the one most people already have.
SMTP_PORT 465
465 is implicit TLS, 587 is STARTTLS. Pick the one your provider documents — the wrong one shows up as a connection failure at boot.
APPROVAL_TOKEN_TTL_MS 86400000
Twenty-four hours. When it lapses the action moves to expired rather than sitting in pending_approval forever, which is what lets a waiting agent stop waiting.
ADEIA_SITE_ORIGINS localhost:5173
Comma-separated origins allowed to call the public site endpoints. The site is served from a different port than the API in development, so the origin has to be named rather than assumed.
ADEIA_TRUST_PROXY false
Whether x-forwarded-for can be believed. Off by default, because anyone can send that header. Turn it on only behind a proxy that overwrites it.
ADEIA_VISIT_SALT adeia-dev-salt
Salt for the visitor hash. No address or user agent is stored, only a hash of them, and the salt is what keeps that hash from being trivially reversible over so small an input space. Set a real value anywhere public.
What you should see

Three outcomes, and nothing else

$50.00 per action
$1,000.00 hard ceiling
$2,000.00 daily cap

201

executed

Inside the fence, it just runs

$25.00 to acct_cloudhost. Under the $50.00 per-action limit, so the policy engine answers allow and the action finishes inside the same request. The agent is told executed and moves on to the next invoice without pausing.

"result": {
  "ledgerEntryId": "led_act_8xk2m4pq7vn3jd6wztc0b",
  "status": "recorded",
  "settled": false
}

settled: false is not a placeholder for a value that arrives later. No payment processor is attached. The action was authorised, evaluated and written down, and it stops exactly where settlement would begin.

202

pending_approval

Over the line, waiting on a person

$500.00 to acct_contractor. Over the $50.00 per-action limit, but under both the $2,000.00 daily cap and the $1,000.00 hard ceiling — so the answer is hold, not deny.

"status": "pending_approval",
"decision": "require_approval",
"decisionReason": "amount 50000 exceeds per-action limit 5000"

Mail goes to APPROVER_EMAIL and the agent blocks. Opening the link renders the page and changes nothing — a GET only draws, and only a POST decides, because mail scanners and link unfurlers open email links unattended. The token is single-use, stored only as a hash, and expires. Ask for $5,000.00 instead and it is past the hard ceiling: refused outright, no email sent, and no button anywhere that lets a tired human wave it through.

03

the record

Every step, in the order it happened

npm run audit -- <actionId> prints the action and its complete trail. Nothing in it is ever edited or deleted, and keys and tokens are stripped as each row is written rather than as it is read.

act_q7w2e9r4t1y6u3i8o5p0a  payment  executed
  params  { amountCents: 50000, recipient: 'acct_contractor' }
  policy  amount 50000 exceeds per-action limit 5000

14:02:13  action.requested         { type: 'payment', params: {…} }
14:02:13  policy.evaluated         { decision: 'require_approval' }
14:02:13  action.pending_approval  { reason: '…' }
14:02:14  approval.sent            { to: 'you@gmail.com' }
14:03:47  approval.granted         { decidedBy: 'you@gmail.com' }
14:03:47  action.executing         { adapter: 'ledger' }
14:03:49  action.executed          { result: {…} }

The agent could not have raised its own limit, could not have reached anything while the payment was held, and could not have approved itself — not because it was instructed not to, but because there is no code path that does it.

When it works

Point your own agent at it

The demo agent is the smallest possible caller — one tool, two invoices, no credentials. Swap it for yours and nothing on the server changes: the policy stays where the agent cannot reach it, the approval still goes to a person, and the trail still records what happened either way.