Skip to content

Latest commit

 

History

79 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ward

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.

What it does

On Telegram, in one loop:

  1. Onboards once — risk label, per-action limit, daily limit → written to Sibyl Memory.
  2. 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.
  3. 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.
  4. Remembers — every spend, every revocation, and whether each counterparty was worth trusting. A fresh session recalls all of it.

How memory is load-bearing

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.

The same record, from every surface

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.

The demo

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.

Architecture

One Bun + TypeScript process. No backend, no database of our own, no vector store.

  • Agent — LangGraph, MemorySaver for per-thread turn state · src/agent/. Flow: guard → intent → router, branching to onboarding / agent / refuse / confirm / wallet; confirm → execute; agent ⇄ tools.
  • MemorySibyl Memory (sibyl-memory-cli[mcp] — local SQLite, FTS5, no vector DB), reached from the Bun process over the sibyl-memory-mcp stdio 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 in src/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. See MULTI-CHANNEL.md.
  • Landing pagepublic/index.html, served at / by the same linking server (src/http/server.ts) whenever WARD_PUBLIC_URL is 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.png for iOS.

Build history and every design decision: Ward-Build-Phases-and-Len3-Infra-Map.md.

Deleting your data

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.

Eligibility gate — checkable from the repo

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 backend

Partner stacks (Base + Virtuals → ×1.25 cap)

Base — 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 ACPhire 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.

Run it

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 dev

OPENAI_API_KEY → the gpt-4o-mini conversational model. CDP_* → the real wallet. ACP_MODE=virtuals + ACP_* → the real counterparty market. See .env.example.

Product-market fit

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.

Troubleshooting

  • 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. Check CDP_PROXY_URL is set for local dev, the boot log printed CDP proxy active for *.coinbase.com …, and the failing host matches *.coinbase.com (widen isCoinbaseHost() in src/net.ts if not). Verify with curl -x "$CDP_PROXY_URL" https://api.cdp.coinbase.com/. @coinbase/cdp-sdk has no per-client fetch option, so the global fetch patch in src/net.ts is the only hook. On Railway (non-blocked region) leave CDP_PROXY_URL unset.
  • 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 with CDP <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's handlerTimeout killing a handler parked on a confirmation, which used to end long-polling and exit the process. The gateway sets it from CONFIRM_TIMEOUT_MS; if a confirmation window is ever made longer than that, the two must move together.

Reading the logs

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.

Prior work

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/ and src/execution/gate.ts.
  • The two-limit designmin(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 in counterparty/, 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.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages