How We Use curate-me.ai as the Backend for Family Manager
Family Manager has no backend of its own. It is a generic customer org on curate-me.ai, talking to a mobile facade any client could use. Here is how the pieces fit, with the real endpoints, auth, and governance.
Claude (Opus 4.8, 1M) via Claude Code, Drafting + fact-gathering
Governed by curate-me.ai
Family Manager is an iOS app for family logistics: forward a school email, snap a flyer, or dictate a note, and it drafts a calendar entry you approve. The interesting part for a developer is what is not in the iOS repo. There is no Family Manager server, no bespoke API, no database schema with a families table.
Instead, the app is a generic customer org on curate-me.ai, the AI-orchestration platform this blog is also built on. It talks to a small mobile facade that any mobile client could use. This post walks through that boundary: the auth model, the endpoints, the extraction pipeline, and the governance every model call passes through.
The setup: two repos, one generic platform
The split is deliberate:
family-manager-ios: the SwiftUI app plusCMKit, a platform-agnostic networking SDK. Deployment target iOS 26.platform/services/backend: a FastAPI monorepo. The mobile facade is one router family among roughly 250 gateway routes.
The contract that keeps it honest is stated in the platform's own architecture doc:
"No Family-Manager-specific endpoints: the family layer is client app + org config + autopilot templates + skills/prompts + org memory. The platform stays a generic AI-work-orchestration platform that any org can use."
There is even a lint-level rule in the API contract: the word "family" never appears in a path, schema field, or error code. A family is just an org. The parent is the owner member. Kids are member or viewer. Internally a household org is typed consumer_household, and that flag gates a few premium identity behaviors and nothing else. The app gets members, budgets, an agent identity, human-in-the-loop approvals, a memory store, and autopilot templates because every org gets those.
The core engine: propose, approve, route
One loop runs the whole product:
- Capture: the app submits raw input (text, a voice transcript, an image).
- Extract: the server turns it into a structured proposal with an absolute date, a destination, and a confidence band.
- Propose: the proposal becomes a pending approval card.
- Approve: the parent approves, edits, rejects, or defers.
- Route: only on approval does the event get written, on the device, through EventKit.
Everything below is in service of that loop staying trustworthy.

Auth: Sign in with Apple to a per-member key
There is exactly one unauthenticated route on the facade: POST /api/v1/mobile/session/apple. It verifies the Apple identity token (JWKS, issuer, audience, expiry, a sha256-matched nonce, private-relay email allowed), finds or bootstraps an org and an owner member, and mints a per-member gateway key:
- The key carries scopes
mobile:approve,gateway:read,gateway:write. - It stores both
org_idanduser_id, so the gateway resolves the org as the tenant, not the individual member. - The plaintext
cm_sk_*is returned exactly once. The server keeps only a SHA-256 hash. Neither the Apple token nor the key is ever logged.
The app stores that key in the iOS Keychain and sends it on every subsequent request. Here is the entire client-side auth surface, from CMKit/Client/CMTransport.swift:
var request = URLRequest(url: url, timeoutInterval: config.timeout)
request.httpMethod = method.rawValue
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Accept")
if let authKey {
// Per-member gateway key. Contract §2.1: X-CM-API-Key.
request.setValue(authKey, forHTTPHeaderField: "X-CM-API-Key")
}
if let idempotencyKey {
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
}
What to notice: there is no custom token exchange and no session cookie. It is the same X-CM-API-Key header any service uses against the gateway. On the server, every authed route calls authenticate_gateway_request: the same function the gateway proxy uses for everything else. The facade never trusts a client-sent org id, and a cross-org read returns 404, indistinguishable from "not found." It fails closed.
The mobile facade: one typed method per endpoint
The facade lives under /api/v1/mobile. A sample of the core router:
| Endpoint | Purpose |
|---|---|
POST /session/apple | Sign in, mint the member key (the only unauthed route) |
GET /session | Who-am-I, org bootstrap, consents |
DELETE /session | In-app account deletion (Apple 5.1.1(v)) |
POST /captures | Submit a capture for extraction (returns 202) |
GET /approvals | Pending approval cards, each with a fresh decision token |
POST /approvals/{id}/decide | Approve, edit, reject, or defer |
POST /devices | Register the APNs token |
POST /receipts, /receipts/{id}/undo | Read-back receipts and undo |
GET /brief | The daily brief |
CMKit maps one typed Swift method to each. The approval call is representative:
public func decide(
approvalID: String,
_ request: DecisionRequest,
idempotencyKey: String? = nil
) async throws -> DecisionResponse {
let body = try transport.encode(request)
let req = try transport.makeRequest(
method: .post, path: "/approvals/\(approvalID)/decide", body: body,
authKey: try await requireKey(), idempotencyKey: idempotencyKey
)
return try await transport.send(req, as: DecisionResponse.self)
}
What to notice: requireKey() throws if the Keychain has no key, so an authed call can never go out unauthenticated. The optional idempotencyKey is the same trust primitive used end to end: a double-tap or a replayed push cannot approve twice.
Capture to proposal, server-side
POST /captures takes a generic envelope, not an endpoint per input type:
{ "kind": "voice_transcript", "payload": { "text": "Mia has soccer Thursday at 6" }, "note": null, "hints": { "now_local": "2026-06-26T08:00:00" } }
kind discriminates between text, voice_transcript (on-device transcription, audio never leaves the phone), and image. It returns 202 Accepted because extraction is async; the proposal arrives later via the approvals list and a push.
Extraction itself is two stages, and the ordering is the point:

- Deterministic first. A no-LLM, timezone-aware extractor resolves relative phrases ("next Tuesday", "5 to 6:30pm") to absolute instants in the member's timezone. If anything is ambiguous it returns
ok=Falsewithambiguous_fields, never a silently-wrong approval. - LLM only on ambiguity, and only with consent. If the deterministic pass cannot resolve it and the member granted third-party-AI consent, an LLM disambiguates the subject only. Any time value the model returns is re-resolved to an absolute instant deterministically on the server. The rule, verbatim from the code: we never trust a model-produced absolute timestamp.
That ordering is a good habit for any LLM feature: do the deterministic work first, escalate to the model only for the genuinely fuzzy part, and re-validate whatever it returns.
Every model call goes through governance
The reason a consumer app can lean on this platform is that the LLM call is not direct to a provider. It is routed through the gateway's governance engine, authenticated as the household org. The stages, from the engine's own docstring:
0. Plan enforcement - subscription status, request quota, model access
0.5 Body size limit
1. Rate limit - sliding window
2. Cost estimate - vs per-request + daily budget
2.5 Hierarchical budget- Org -> Team -> Key
4. PII scan - Presidio NER + regex for secrets
4.5 Content safety - prompt injection / jailbreak / exfiltration
4.6 Security scan - encoded payloads, advanced injection
5. Model allowlist
6. HITL gate - flag high-cost requests for human approval

Checks run in order and short-circuit on the first denial, before any upstream call. This matters specifically for Family Manager because the input is a forwarded email from a stranger (a school, a coach, a clinic). That untrusted body is exactly what stages 4.5 and 4.6 are built to guard. A fresh org with no explicit policy still gets safe defaults: PII scanning on, content safety on. The app inherits all of it without writing a line of governance code.
The agent has an identity, and an inbox
Each org gets one AI representative provisioned as a real, licensed Microsoft 365 user (not a shared mailbox, so it has a real address and directory presence). For a household, that mailbox is how forwarded email becomes a capture:
- Microsoft Graph fires a webhook when a message lands in the agent's inbox.
- The dispatcher resolves the org by mailbox, fetches the message, and rate-limits per sender.
- For a household org, the message is routed into the same
/capturespipeline the app uses, rather than the autopilot email-threading path.

There is also a simpler default path: each household can get a single non-guessable forwarding alias (h-<base32>@in.curate-me.ai) with a verified-sender allowlist. Both paths land in the same capture-to-proposal engine. The privacy invariants there are load-bearing: never log a sender address or verification token, and use a hashed key in rate-limit storage, never a raw email.
Push that carries no authority
Approvals are actionable push notifications, but the design rule is that a notification can never be the authority to act. The APNs payload uses a category (CM_APPROVAL_V1) with approve and edit actions and stays under the 4 KB cap. Tapping "approve" does not approve anything by itself. The app still calls POST /approvals/{id}/decide with a single-use decision token that the server issued with the card. A replayed or spoofed notification has nothing to spend.
What the platform hands the app for free
Building this as a generic org instead of a bespoke backend means the app inherits, with no app-specific server code:
- Tenancy and auth: per-member keys, org-scoped isolation, fail-closed cross-org access.
- The full governance chain: PII scanning, content safety, budgets, model allowlists, HITL gates.
- An agent identity: a real mailbox and inbound-email-to-capture bridge.
- Push, receipts, and memory: generic notification, read-back, and per-member fact storage.
- Cost controls: hierarchical Org → Team → Key budgets, enforced before the call, not after the bill.
Takeaways
- A consumer app can be a thin client on a governed B2B platform. The hard parts (tenancy, PII, budgets, an agent identity) are platform capabilities, not app features.
- Keep the API generic. Refusing to let "family" into a path or schema kept the surface reusable and the isolation simple to reason about.
- Deterministic first, LLM second, re-validate always. The extractor resolves what it can without a model and never trusts a model's absolute timestamp.
- Route untrusted input through governance on purpose. A forwarded email is exactly the content the safety stages exist for, so the LLM call goes through the gateway as the org, not direct to a provider.
- Authority lives in tokens, not notifications. Single-use decision tokens make a replayed push worthless.
The next piece worth writing up is the on-device half of the trust spine: how the app verifies an approved write actually landed on the calendar before it reports success. That lives in the iOS repo, and it is the subject of the build post.
Comments
Loading comments...