iMessage Conversation API — REST send + inbound webhooks.
Same shape as every example in this repo (see the root README): a
Claude brain with per-conversation memory, a provider-agnostic message
loop, a zero-credential terminal provider for local testing, and the real
LoopMessage provider (src/providers/loopmessage.ts) wired to LoopMessage's API.
bun install
cp .env.example .env # add ANTHROPIC_API_KEY (terminal mode needs nothing else)
bun run dev # terminal mode — chat with the agent locally, zero credentials
bun run start # PROVIDER=loopmessage: starts the webhook server + REST senderThis example also integrates Agentcard for companies: a connected user can ask the agent to issue/inspect their own Agentcard virtual cards. Cards are isolated per (this app, that user) — never a shared account.
Three parts, in src/agentcard/:
- Client (Part 1) — at startup the app self-registers a public OAuth
client via Dynamic Client Registration (no admin, no secret).
runtime.tsuses a pinnedAGENTCARD_OAUTH_CLIENT_IDif you set one, otherwise it registers a fresh DCR client. - User auth (Part 2) — OAuth 2.1 + PKCE.
connect-server.tsruns a small HTTP server (GET /connect?space=<id>→ Agentcard/authorize;GET /callback→ token exchange) in the same process as the brain, so it shares the token store (store.ts, persisted to~/.agentcard-loopmessage-tokens.json). - MCP (Part 3) —
brain.tsopens an@ai-sdk/mcpclient with the user's bearer token and exposes a curated card toolset (create_card,list_cards,get_card_details,check_balance,close_card,approve_request,list_transactions,list_all_transactions,get_plan—buyis omitted).
When a conversation has no token the brain is plain chat; if the user asks
about cards it hands them their /connect?space=<id> link. When a token is
present the model can create/inspect cards in a multi-step loop (new cards
default to $1.00; the card-creation approval gate is auto-resolved).
| Env var | Required for cards | Notes |
|---|---|---|
AGENTCARD_PUBLIC_URL |
yes | publicly reachable base URL (a tunnel in dev); callback is <url>/callback |
AGENTCARD_CONNECT_PORT |
no | connect/callback server port (default 8912) |
AGENTCARD_OAUTH_CLIENT_ID |
no | pin a client instead of self-registering (DCR) |
AGENTCARD_OAUTH_CLIENT_SECRET |
no | only with a pinned confidential client id |
export ANTHROPIC_API_KEY=$(cat ~/.anthropic_key)
cloudflared tunnel --url http://localhost:8912 # prints an https URL
export AGENTCARD_PUBLIC_URL=https://<printed-tunnel>.trycloudflare.com
bun run dev # terminal modeThen in the chat: type "make me a card" → the agent replies with a
/connect?space=terminal link → open it, log into your Agentcard account → the
callback prints ✓ connected space=terminal → back in the terminal type "make
me a $1 card" and the agent issues it and reports the last 4 digits.
bun test # offline: loop e2e + provider plumbing + Agentcard (PKCE/token/MCP)
RUN_LIVE=1 bun test # also hits the real Anthropic API (needs ANTHROPIC_API_KEY)test/e2e.test.ts— drives the full loop with a scripted provider + a fake Anthropic client; asserts the round-trip and per-conversation memory.test/provider.test.ts— stands up a mock LoopMessage endpoint, POSTs a real-shaped inbound webhook to the provider, and asserts the reply is sent back out. Proves receive → brain → send without any real credentials.test/agentcard.test.ts— offline Agentcard suite: PKCE math, token exchange/refresh against a mock/token(incl. confidentialclient_secret- refresh-token rotation), and the MCP flow (initialize → tools/list →
tools/call with the approval-required → approve_request gate) driven through
the real
@ai-sdk/mcpclient against a mock JSON-RPC MCP server, asserting the user's bearer token reaches the server.
- refresh-token rotation), and the MCP flow (initialize → tools/list →
tools/call with the approval-required → approve_request gate) driven through
the real
test/live.e2e.test.ts— gated byRUN_LIVE=1; real Claude round-trip.
src/providers/loopmessage.ts implements LoopMessage's documented contract
(send + inbound webhook). Going fully live needs a LoopMessage account and the
credentials below, plus a public webhook URL pointed at this server — exactly the
same "terminal verified, live needs creds" status as the Photon sandbox.
| Env var | Required | Notes |
|---|---|---|
LOOPMESSAGE_AUTH_KEY |
yes | Authorization key |
LOOPMESSAGE_SECRET_KEY |
no | Loop-Secret-Key (legacy accounts only) |
LOOPMESSAGE_SENDER_NAME |
no | sender name id (required to start a new convo) |
PORT |
no | webhook server port (default 8788) |