A personal crypto agent whose memory is the authorization layer for moving money on Base.
Ward cannot exceed the limits you once gave it, and it gets better at knowing which other agents to trust — because that policy lives in Sibyl Memory, not in code. Delete the memory and the agent has no basis for authority: it refuses to act, even when the chain would still permit the spend.
An agent that cannot exceed what you once told it, and gets better at knowing who else to trust, because it remembers.
On Telegram, in one loop:
- Onboards once — risk label, per-action limit, daily limit → written to Sibyl Memory.
- Connects a wallet — a Coinbase CDP smart account for you, a CDP Server Account as the agent spender, and a revocable on-chain USDC Spend Permission scoped to your daily limit.
- Acts within
min(memory cap, on-chain allowance)— an x402 payment for premium on-chain data, or hiring another agent via Virtuals ACP to assess a token — both on one spending ledger, every one confirmed with the real numbers. - Remembers — every spend, every revocation, and whether each counterparty was worth trusting. A fresh session recalls all of it.
Every field except risk_label grows only through use. Each is read on the
critical path; delete the record and each read fails closed.
The record is keyed by a Ward user, not by a chat account. Telegram, Discord and
an MCP client all resolve to one principal (ward_<ulid>), so every row below is one
object seen from three places: one daily cap, one spend ledger, one revocation log.
A second app is not a second allowance — that is asserted in
test/identity.cross-channel.test.ts.
| Sibyl Memory field | Where it's read | What it changes | Deleted → |
|---|---|---|---|
ward.authorization/<id> (the record) |
memory/store.ts read() — src/agent/nodes/router.ts, execute.ts, confirm.ts |
exists? → proceed · missing? → refuse, explain why | every action request is refused; no scope, no budget, no trust (user-triggerable: /forget_me) |
standing_caps.per_action_limit_usd |
src/execution/gate.ts evaluateGate |
amount over it → blocked before any confirmation | — |
standing_caps.daily_limit_usd |
gate.ts, via spentToday() |
sum(spent_ledger, today) + amount > cap → blocked |
— |
spent_ledger[] (append-only) |
spentToday() — sums x402 + acp_job for the current UTC day |
one number, one cap, across every action type | the cap is unbounded (but there's no record, so it refuses first) |
revocation_log[] (append-only) |
isRevoked() — fresh read before every action |
a revoked action_type blocks that path immediately, mid-session |
— |
acp_job_history[] (append-only) |
trustScore() — read before choosing a counterparty |
a low-trust counterparty is flagged; the agent narrates 0.56 → 0.60 after each job |
the agent has no memory of who it trusts |
x402_ledger[] (append-only) |
endpointTrust() |
per-endpoint success/failure feeds a derived trust score | — |
ward.wallet/<id>.spend_permission.status |
confirm.ts / execute.ts |
"revoked" → refuse even with the memory record intact (the two-limit design) |
— |
ward.mcp_grant/<token_hash> (src/mcp/grants.ts) |
evaluateGate via execution/perform.ts |
an MCP client may spend without asking — inside min(grant, cap, allowance) |
the client is back to propose-only; it can ask, never act |
ward.conversation.<id> (HOT state, src/agent/summary.ts) |
the agent node's system prompt |
a fresh session recalls the conversation, not just the caps | — |
The tier map, the exact JSON shape, and which function touches which field are in
memory/README.md.
| Property | Proof |
|---|---|
| One daily cap, shared. Spend $8 of $10 on Telegram, and Discord is refused the next $9 — with nothing broadcast. | test/identity.cross-channel.test.ts |
Revocation is instant everywhere. isRevoked() re-reads before every action, so pausing data purchases on Discord refuses the next Telegram purchase mid-session. |
same |
The deletion gate crosses channels. Delete ward.authorization/<id> and Telegram, Discord and MCP all refuse — and MCP cannot queue a proposal to route around it. |
same |
| Identity survives deletion. Only authority was deleted: the user is still known on every channel, and still refused on every channel. | same |
The gate gets stronger with more surfaces, not weaker, because there is only ever one record behind them. Channels differ in how they render a message and how they ask for a confirmation — never in what the user is allowed to do.
recall → decide → pay/hire → execute — and none of it works without Sibyl Memory.
Fresh-session recall (timestamped) → the memory-gated refusal → one x402 payment on Base → revoke the Spend Permission on-chain → an ACP hire with trust write-back. Full script: DEMO.md.
One Bun + TypeScript process. No backend, no database of our own, no vector store.
- Agent — LangGraph,
MemorySaverfor per-thread turn state ·src/agent/. Flow: guard → intent → router, branching to onboarding / agent / refuse / confirm / wallet;confirm → execute;agent ⇄ tools. - Memory — Sibyl Memory (
sibyl-memory-cli[mcp]— local SQLite, FTS5, no vector DB), reached from the Bun process over thesibyl-memory-mcpstdio server ·memory/. - Wallet — CDP smart account + CDP Server Account spender + on-chain Spend
Permission ·
src/wallet/, WALLET.md. - Base execution — the shared gate + a keyword-matched x402 catalog (GET or
POST-with-body,
{subject}-templated) + the x402 / ACP paths ·src/execution/, X402.md. (A capped swap and a USDC transfer are built but switched off —src/agent/transfers.ts— until their on-chain paths are verified.) - Counterparty market — Virtuals ACP hire with trust write-back ·
src/acp/, ACP.md. - Interface — Telegram (Telegraf; streamed edits, HTML, 4096-split, typed
confirmations) ·
src/telegram/— and Discord (discord.js; DM-only, native markdown, 2000-split, button confirmations) ·src/discord/. Both drive one channel-free turn loop insrc/gateway/, against one authorization record: linked accounts share a daily cap, a spend ledger and every revocation. Ward is also an MCP server (src/mcp/,MCP.md) — propose-only by default: an MCP client can ask for a spend but not approve one, because it holds a token rather than being a person. SeeMULTI-CHANNEL.md. - Landing page —
public/index.html, served at/by the same linking server (src/http/server.ts) wheneverWARD_PUBLIC_URLis set. A self-contained bundle: fonts, React and the favicon are embedded, so it renders without fetching anything and there is no second service to deploy.public/favicon.svg— the header mark on a solid tile, so it survives 16px on dark browser chrome — is also served at/favicon.svg, with/apple-touch-icon.pngfor iOS.
Build history and every design decision: Ward-Build-Phases-and-Len3-Infra-Map.md.
The deletion the gate tests is not an operator-only trick — it is a command:
/forget_me → reads back exactly what will go, and hands you a code
/forget_me <code> → applies it
It removes the ward.authorization record (the caps and all four ledgers) and the
episodic conversation summary. Ward then refuses every action, on every channel,
until you set new limits — even where your on-chain allowance would still permit
the spend.
What it deliberately keeps, and why:
| Kept | Why |
|---|---|
ward.wallet/<id> |
account_key is what the smart-account address is derived from. Re-onboarding puts you back on the same address and funds. |
ward.identity / ward.accounts |
Deleting these would mint a fresh principal, a fresh account_key, and strand your funds at an address nothing points to. |
| The COLD journal | The audit trail of your own actions, including the deletion. Never read onto the critical path or into a prompt. |
| The on-chain Spend Permission | A memory operation should not depend on a gas transaction that can fail. revoke my permission is the separate, explicit action. |
It is two steps, single-use and principal-bound; it is read off the slash-command
text and routed outside the graph, so no injected text can trigger it; it is refused
for MCP clients, because a bearer token is a process rather than a person; and it
announces itself to every other linked account, so a hijacked session cannot wipe a
policy quietly. test/forget-me.test.ts asserts each of
those. Design notes: PHASE-17.md.
scripts/forget-auth.ts is the same delete from a terminal — the operator path, and
the one a judge can run against the repo without a chat account. Both call
forgetAuthorization in memory/store.ts, so they cannot drift.
The judges' own tests, as first-class CI test files:
| File | Asserts |
|---|---|
test/deletion-gate.test.ts |
with the record a spend executes; remove ward.authorization from Sibyl Memory → the same request refuses, no transaction broadcast |
test/revocation.test.ts |
pause an action type mid-session → the next spend of that type in the session is refused (fresh revocation_log read) |
test/onchain-revoke.test.ts |
revoke the Spend Permission on-chain → the next spend refuses even with memory intact |
test/daily-cap.test.ts |
spent_ledger sum at/over daily_limit_usd → the next action of either type is blocked |
test/forget-me.test.ts |
the user deletes their own record with /forget_me → the same refusal; the wallet, the links and the journal survive it |
The stub wallet / ACP providers log every call, so "no transaction broadcast" is an
assertion, not a comment. deletion-gate runs on the fs backend in CI and on the
real sibyl-mcp backend under SIBYL_MEMORY_MCP_TEST=1
(test/memory.sibyl-mcp.test.ts).
scripts/demo-deletion.sh does it live on Telegram.
bun test # 574 pass on the fs backendBase — two qualifying actions from the rules:
| Action | Path | Status |
|---|---|---|
| Contract interaction — Spend Permission grant / revoke | src/wallet/cdp.ts createSpendPermission / revokeSpendPermission |
code-complete; CDP field names from SDK types, verify live (WARD_CDP_TEST=1) |
| x402 payment | src/wallet/cdp.ts payX402 (x402-fetch, EIP-3009, Coinbase facilitator) |
code-complete; verify live |
A capped swap (src/wallet/cdp.ts swap) and a USDC transfer (sendUsdc) are also
built, but switched off (src/agent/transfers.ts)
until their on-chain paths are live-verified — Ward declines a swap or send request
and neither appears in /start or /help.
Virtuals ACP — hire an agent to assess PEPE → job → escrow (settles on Base) →
validateExternalData on the result → appendAcpJob({…, trust_delta}) → the next
hire reads the re-derived trust score. The trust loop works today against a
[SIMULATED] counterparty. The real path (ACP_MODE=virtuals) is a hard
go/no-go spike — ACP.md. If it doesn't settle end-to-end, it's cut
cleanly, never faked.
An ACP job is charged to the user's wallet, pulled through their Spend Permission like any other spend — not absorbed by a Ward-side float. The wallets Ward runs are conduits, not floats; see the funding table in ACP.md.
Counterparty disclosure. The seller agent Ward hires on the real path
(counterparty/) has its own ACP registration, wallet and key,
and settles real escrow — but it is run by the same team as Ward, not an
independent third party. It sells a reproducible token-risk report (every report
cites its sources and the sha256 of the response it was derived from, so the work
can be re-run rather than trusted). Prefer an independently-registered agent if one
offers the service.
Without keys, everything runs on deterministic stubs (SIBYL_MEMORY_MODE=fs, no
CDP_*, ACP_MODE=stub, no OPENAI_API_KEY) — enough to demo the whole memory
loop.
bun install
cp .env.example .env # TELEGRAM_BOT_TOKEN from @BotFather,
# and/or DISCORD_BOT_TOKEN — at least one
pipx install 'sibyl-memory-cli[mcp]' # Python 3.10+ — see SIBYL-MEMORY.md
sibyl init # or run with SIBYL_MEMORY_MODE=fs
bun run devOPENAI_API_KEY → the gpt-4o-mini conversational model. CDP_* → the real
wallet. ACP_MODE=virtuals + ACP_* → the real counterparty market. See
.env.example.
No fabricated numbers. The validated pain point is public and well-documented: crypto users will not give an autonomous agent unrestricted spend authority — "the agent has my keys" is the standing objection in every agent-trading thread. Ward's answer is a memory-scoped, on-chain-revocable authorization layer where the limits are the user's and the agent's own policy can only be stricter. There is no waitlist or usage to cite; this section is honest and modest by design.
- Any Coinbase / CDP / x402-facilitator call fails (timeout,
403/451, "region not supported", TLS reset): suspect the geoblock first, before the SDK usage. Coinbase blocks some regions. CheckCDP_PROXY_URLis set for local dev, the boot log printedCDP proxy active for *.coinbase.com …, and the failing host matches*.coinbase.com(widenisCoinbaseHost()insrc/net.tsif not). Verify withcurl -x "$CDP_PROXY_URL" https://api.cdp.coinbase.com/.@coinbase/cdp-sdkhas no per-client fetch option, so the globalfetchpatch insrc/net.tsis the only hook. On Railway (non-blocked region) leaveCDP_PROXY_URLunset. - A turn hangs for minutes: the CDP SDK has no deadline of its own — an
unreachable CDP retried internally for 241 seconds in production before throwing.
Calls that submit no transaction (account, permission and balance reads) are now
bounded by
CDP_TIMEOUT_MS(default 15s) and fail withCDP <what> did not answer within 15s; look for that line before suspecting the model. - Telegram goes quiet after a while: check for
TimeoutError: Promise timed out after 90000 milliseconds— that is Telegraf'shandlerTimeoutkilling a handler parked on a confirmation, which used to end long-polling and exit the process. The gateway sets it fromCONFIRM_TIMEOUT_MS; if a confirmation window is ever made longer than that, the two must move together.
Ward writes one key=value line per event, so a Railway log shows who is talking to
it and where a slow turn went:
… ward event=msg.in channel=telegram account=706456243 chat=706456243 chars=24 text="risk score on PEPE"
… ward event=turn.start channel=telegram account=706456243 thread=telegram:706456243:1 chars=24 …
… ward event=intent action=x402_data_purchase source=table ms=0.6
… ward event=model.call model=gpt-4o-mini ms=1294.6 turns=1 tool_calls=0
… ward event=confirm.ask channel=telegram text="Buy 'Token Risk Score' (~$0.05). $0.00 of your $100 daily cap used …"
… ward event=confirm.answer answer=yes ms=8214.0
… ward event=turn.done channel=telegram ms=11602.3 chars=134 …
source on the intent line says what a turn's classification cost: table (rules,
free), smalltalk (nothing money-shaped, model call skipped), llm (one round trip),
fallback (that call failed — the reason is logged next to it). model.call and
turn.done carry the milliseconds, which is how to tell a slow model from a slow
chain read. Slash-command arguments are never printed — a link code is a secret — and
WARD_LOG_TEXT=0 drops message text entirely, keeping the lengths.
Ward was started for this hackathon on 2 September 2026 (first commit
f8fdc6c), and everything it is judged on was built in the build window:
- Sibyl Memory as the authorization substrate — the record, the four
append-only ledgers, the deletion gate, and every critical-path read in
memory/andsrc/execution/gate.ts. - The two-limit design —
min(memory cap, on-chain allowance)over a revocable Base Spend Permission (src/wallet/, WALLET.md). - One principal across channels — Telegram, Discord and MCP resolving to one
ward_<ulid>and one ledger (src/identity/, MULTI-CHANNEL.md); Ward as an MCP server with capped execution grants (src/mcp/, MCP.md). - The ACP trust loop — read trust before hiring, write the outcome back
(
src/acp/, ACP.md), and the seller agent incounterparty/, which is run by the same team (disclosed above).
Ward does adapt a small, named set of patterns from Len3, a production portfolio-intelligence agent by the same team, with Len3hq's sign-off: the LangGraph node topology, the structural human-in-the-loop interrupt, the Telegram gateway shape, the guardrails boundary, the intent table, the shape of the approval-gate decision function, the x402 request → 402 → pay → retry orchestration, and the recency-weighted trust formula. No Len3 source is copied verbatim; each was reimplemented for Bun + TypeScript, Sibyl Memory and Base. The full list, file by file, is in ATTRIBUTION.md.
Third-party services and libraries (Sibyl Memory, Coinbase CDP, x402, Virtuals ACP, LangGraph, Telegraf, discord.js) are used as external dependencies and are also listed there.
MIT — see LICENSE.