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-video for the live-call orb, expo-audio for voice sync, expo-secure-store for the JWT bearer, expo-contacts for the contact picker.
  • Fastify backend (afterhold-api/) — TypeScript, better-sqlite3, Zod for input validation, bcryptjs + jsonwebtoken for auth, @call-e/calle SDK for real calls. A MockCallegate lives 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.md plus 4 example payloads, 4 reference docs, and 2 helper scripts. The backend also exposes a Model Context Protocol endpoint at /v1/mcp with plan_call / run_call / get_call_run / list_runs so 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

Share this project:

Updates

Submission history