Adeia

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.

START AT STEP ONE

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.

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

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

    cp config/.env.example .env

    Then edit .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.

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

    ngrok http 3000

    Then paste the https URL into .env

    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.

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

    Run

    npm run seed

    Prints

      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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    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 — a lost key means seeding a new project, not recovering the old one.

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

    Run

    npm run dev

    Prints

    [adeia] listening on http://localhost:3000  (db: ./adeia.db)
    [adeia] adapters: ledger
    [adeia] NO PAYMENT PROCESSOR ATTACHED — payments are authorised and recorded;
    [adeia]   no money moves. Register a processor adapter to change that.
    [adeia] approvals: you@gmail.com via https://your-tunnel.ngrok-free.app
    [adeia]   sending as you@gmail.com over smtp smtp.gmail.com:465 as you@gmail.com

    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.

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

  7. 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, only this agent does.

    Run

    npm run demo

    Prints, partway through

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

  8. 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 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, 127.0.0.1:5173

    Comma-separated origins allowed to call the public site endpoints. This page 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.

  • ANTHROPIC_API_KEY

    demo agent only

    Read by the example agent in examples/demo-agent, never by the server. Adeia calls no model, and the model never sees anything but the SDK.

What you should see

Three outcomes, and nothing else

  • Per action $50.00
  • Daily cap $2,000.00
  • Hard ceiling $1,000.00
  1. 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.

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

  3. 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, currency: 'usd', recipient: 'acct_contractor' }
      policy  amount 50000 exceeds per-action limit 5000
      result  { ledgerEntryId: 'led_act_q7w2…', status: 'recorded', settled: false }
    
    14:02:13  action.requested         { type: 'payment', params: {…} }
    14:02:13  policy.evaluated         { decision: 'require_approval', reason: '…' }
    14:02:13  action.pending_approval  { reason: '…' }
    14:02:14  approval.sent            { to: 'you@gmail.com', expiresAt: '…' }
    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.

BACK TO THE OVERVIEW