Inspiration
Every week I spend real time on the phone chasing couriers, confirming clinic appointments, and asking utilities about outages. None of those calls need my full attention — they just need to happen. CALL-E was the first API I saw that lets me outsource that work to a real voice agent. But every time I wanted to use it, I'd have to sit down and write a script. I wanted an iOS app where the phone errands are a tap away — the way you'd send a message, not the way you'd deploy code.
What it does
AfterHold is a phone-call mission control for iPhone. You pick a contact, type (or dictate) the goal, choose an archetype — courier, clinic, restaurant, utility, general — and tap Place Call. CALL-E does the actual phone call; you watch the live screen; you get a structured brief when it wraps up. One-shot missions are the simple case. Recurring missions (daily / weekdays / weekly) are the killer feature — once a chain is running, the server spawns the next previewed mission at the right time and dials it without you having to remember. Voice sync lets you dictate the goal hands-free, and toast notifications surface status changes across every screen so you know the moment a brief is ready.
The server is opinionated about safety. Every outbound call is gated by user consent, an allowlist of numbers, a quiet-hours window, a per-hour rate cap, and a global CALLE_ENABLED kill switch. The iOS app never holds the CALLE API key — that lives in the backend .env only.
How we built it
The repo is three pieces:
- iOS app (
AfterHold/) — Expo SDK 57, React Native 0.86, React 19, TypeScript.expo-videofor the live-call orb,expo-audiofor voice sync,expo-secure-storefor the JWT bearer,expo-contactsfor the contact picker. - Fastify backend (
afterhold-api/) — TypeScript,better-sqlite3, Zod for input validation,bcryptjs+jsonwebtokenfor auth,@call-e/calleSDK for real calls. AMockCallegatelives next to the real adapter so the demo video works without burning free-tier credits. - Skill artefact (the PR-shaped bundle) —
plugins/afterhold-ivr-errand/SKILL.mdplus 4 example payloads, 4 reference docs, and 2 helper scripts. The backend also exposes a Model Context Protocol endpoint at/v1/mcpwithplan_call/run_call/get_call_run/list_runsso any MCP-aware agent (Cursor, Claude Code) can drive the same primitives.
The mission state machine is draft → previewed → queued → planning → dialing → in_conversation → wrapping → {completed | voicemail | failed | canceled} with a guarded transition table. The recurring engine hooks into setStatus: when a mission with recurrence_kind != 'none' goes terminal, a child mission is auto-spawned in previewed with schedule_at set, and a 15-second sweeper promotes due children and dials them — only for recurrence_source_id IS NOT NULL rows so user-scheduled one-offs are never surprised.
Challenges we ran into
The @call-e/calle SDK ships as ESM-only — import { CalleClient } from '@call-e/calle' blows up at module load under tsx. I worked around it with a lazy dynamic import() stashed in a promise inside the adapter, so the SDK only loads once per process.
The free-tier CALLE account only supports 1 concurrent call on the shared default line. When a previous run was cancelled locally, CALLE didn't release the slot — the next call sat at queued indefinitely. I added a 75-second stuck-queue timeout in the worker that fails the mission fast with a clear "CALL-E is busy" message instead of leaving the user staring at a frozen timer.
The recurring engine needed to know which missions were "user-scheduled once" vs "user-scheduled recurring". I solved that with a recurrence_source_id column — a child of a recurring chain has it set to its parent's id; one-off missions have it null. The sweeper filters on that exact condition so user-set one-offs never auto-start.
Accomplishments that we're proud of
- Real CAL-E calls on a free-tier account with zero mock fallback during the demo (just smart slot management).
- A 5-turn full-transcript brief captured end-to-end and rendered turn-by-turn on the iOS brief screen — the user can read exactly what was said, who said it, and what was decided.
- Server-side safety story — the iOS client literally cannot bypass the consent gate, allowlist, or rate cap, because those gates live in the Fastify handler and the iOS client never sees the API key.
- MCP server so the same mission primitives work from any agent runtime, not just the iOS app.
What we learned
- A free-tier CALLE account is a concurrency sandbox — design for it, don't fight it.
- ESM-only SDKs are a real trap under tsx; the lazy
import()pattern is the cleanest fix. - Recurring missions need an explicit "this came from a chain" marker. Without it, any scheduled mission looks like a recurring one and the user is confused when it auto-dials.
- Provider ordering matters in React: hooks that consume a context must live inside the provider's subtree, not at the same level. A bridge component (
<NotifierBridge enabled={!!user} />) is the clean fix.
What's next for AfterHold
The roadmap I'd want to ship next: (1) KYC + a dedicated CALLE outbound number so we can run up to 10 concurrent calls instead of 1, (2) push notifications via APNs so toasts aren't the only notification surface, (3) a Calendar integration that auto-creates appointments from successful briefs, (4) shared "Mission Templates" inside teams so support workflows are reusable across an org. The skill shape is already portable — anyone with the SDK key can run the same backend and the same plugin from a different stack.
Then "Built with" field, copy-paste this in:
Expo SDK 57, React Native, TypeScript, Fastify, SQLite, CALLE Developer API, Model Context Protocol (Streamable HTTP), Zod, JWT, better-sqlite3
Built With
- calle
- expogo
- fastify
- jwt
- mcp
- react-native
- sqlite
- typescript
- zod
Log in or sign up for Devpost to join the conversation.