I maintain several iOS apps, each with its own repository, App Store Connect record, backend, analytics project, and release rhythm. That is manageable until I need to answer a small question:
How are the latest reviews looking for Pass Maker?
The answer used to involve opening App Store Connect, finding the right app, remembering which report had the useful data, and then copying the result somewhere I would see it again. A user report had a similar problem in reverse. It arrived in an inbox, lost its app version and device context, and eventually had to be copied into GitHub by hand.
I wanted one place where an operation could start and remain understandable. Slack was already where I discussed releases and bugs, so I made it the interface rather than adding another dashboard.
The result is Indie Ops: a private, event-driven Cloudflare Worker that routes app events into per-app Slack channels, starts longer jobs only when requested, and keeps the resulting state in D1. The current system also exposes the same authenticated operations through MCP, so Cursor, Codex, or ChatGPT can be the conversational front end when Slack is not the right surface.
This is part one of Building Indie Ops. Part two follows an in-app feedback report through validation, durable storage, Slack triage, and GitHub. Part three follows a verified pull request through Xcode Cloud and private TestFlight.
This post describes the architecture and the boundaries. Credentials, route IDs, and production configuration are intentionally omitted.
Slack Is the Interface, Not the Database
The first design had a single operations channel. It received everything: releases, reviews, revenue events, user feedback, and backend failures. That worked for a week and then became a stream I did not trust.
The current design gives each app a set of topics:
passmaker-general
passmaker-user-feedback
passmaker-bug-reports
passmaker-ratings-reviews
passmaker-metrics
passmaker-ideas
passmaker-releases
passmaker-revenuecat
passmaker-alerts
The exact channels are configuration, not code. A route is an (app, topic, channel) record in D1:
CREATE TABLE slack_routes (
app_slug TEXT NOT NULL,
topic TEXT NOT NULL,
channel_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
UNIQUE (app_slug, topic, channel_id)
);
This makes the channel itself carry useful context. A message in an app's metrics channel already identifies both the app and the subject. The user does not need to type the app name into every request.
It also means a new topic does not require a Worker deployment. I create the channel, invite the bot, and add a route. If the route does not exist yet, Indie Ops still records an incoming event without inventing a fallback channel.
That last behavior is important. There is no global critical channel where every provider can silently dump a message. A missing route is a configuration problem that can be repaired and replayed, not a reason to create a noisy new source of truth.
Two Different Kinds of Work
The system has a useful distinction between events that a provider can push and reports that a person asks to pull.
Provider event ──> authenticate ──> record once ──> route to Slack
Human question ──> acknowledge ──> start a job ──> report back to the thread
Apple can push app-version and build state changes through App Store Connect webhooks. Mixpanel can push configured alert events. An app backend can report a real operation failure. These are event streams. The Worker authenticates them, stores an idempotent event, resolves every enabled route, and records delivery per destination.
App Store Connect analytics and written reviews are different. Apple does not provide a review webhook, and analytics reports may be delayed or suppressed by privacy thresholds. I pull those only when someone asks for them. A scheduled job that continuously collects every dataset would produce stale data, cost more, and still would not answer the question that was actually asked.
The rule is simple:
Providers push lifecycle changes. A human pulls reports when they want them.
The Architecture
The deployed path now looks like this:
Apple webhooks
│
▼
Slack command/mention ──> Cloudflare Worker ──> D1
│ │ │
│ │ └── routes, jobs, receipts
│ │
│ └── Cloudflare Queue for feedback
│
├── workflow_dispatch ──> GitHub Actions
│ │
│ └── asc CLI
│
└── authenticated callback <── report/result
│
▼
original Slack thread
The Worker is a coordinator. It verifies Slack signatures and provider signatures, resolves app and topic routes, creates durable jobs, dispatches background work, and posts results back to the originating thread.
D1 stores the state that cannot be left in Slack: route mappings, event identities, per-channel delivery receipts, report jobs, feedback records, release requests, and their terminal states. Slack is where I read and discuss the result, not where the only copy of the result lives.
GitHub Actions is a narrow execution boundary for work that needs a native tool. For an App Store Connect report, the Worker starts a workflow with an app slug, report type, job ID, and time window. The runner authenticates with a read-only App Store Connect key, runs asc, and returns a compact result to an authenticated Worker callback. It never receives a Slack token.
Cloudflare Queues provide a different boundary for user feedback. Feedback must be accepted even when Slack is unavailable, so the Worker commits it to D1 first and lets a consumer deliver it asynchronously. That flow is the subject of part two.
Commands Should Be Boring
I support both a slash command and Slack's app_mention event:
/indie brief 7d
/indie metrics aeronautical 30d
/indie reviews
/indie release
@Indie Ops how are things looking today?
@Indie Ops show me the newest written reviews
@Indie Ops what is happening with the build?
The parser is intentionally deterministic. It looks at explicit words first and the channel topic second:
function inferReport(text: string, topic?: string) {
const value = text.toLowerCase();
if (/\b(review|reviews|rating|ratings)\b/.test(value)) return "reviews";
if (/\b(release|build|testflight|submission)\b/.test(value)) return "release";
if (/\b(metric|download|impression|session|sales)\b/.test(value))
return "metrics";
if (topic === "ratings-reviews") return "reviews";
if (topic === "releases") return "release";
if (topic === "metrics") return "metrics";
return "brief";
}
The synchronous request path does almost nothing:
- Verify Slack's signature over the exact raw request body.
- Resolve the app, topic, and requested operation.
- Create an idempotent job.
- Post an acknowledgement in the originating thread.
- Dispatch the longer work.
- Return before Slack's acknowledgement deadline.
The final report is posted back to that same thread. A repeated Slack delivery or callback sees the existing job state and becomes a no-op.
Metrics use the latest complete reported days available from Apple and compare them with the previous period. The report prints the actual date range for each dataset because impressions, downloads, proceeds, and subscriptions do not necessarily become available on the same day. A polished percentage without a visible date range is more misleading than a missing number.
The Assistant Is an Adapter, Not a Credential Holder
The newest addition is a native MCP endpoint. Cursor, Codex, or ChatGPT can connect to it and ask:
How many Pro screen presentations did AeroNautical have in the last 14 days?
How are my App Store metrics looking this month?
Tell me about this exact RevenueCat customer.
The assistant interprets the question. Indie Ops remains responsible for authentication, app allowlisting, permissions, idempotency, provider calls, and output shaping. The assistant never receives the Slack bot token, App Store Connect key, Mixpanel export credential, or RevenueCat dashboard session.
The MCP surface is deliberately small:
apps_list
mixpanel_event_stats
user_dossier
app_store_metrics_start
app_store_job_status
App Store requests are asynchronous. The assistant starts a report and polls the stored job rather than receiving a provider credential and making an unbounded request itself. The direct MCP path does not need a Slack thread, while the legacy HTTP bridge can preserve a supplied Slack channel and thread when a result should appear there.
Slack can also launch a Cursor Cloud Agent. In that setup, Slack is still the notification surface, Cursor is the natural-language and coding surface, and Indie Ops is the execution layer. Repository routing rules choose the correct app repository before the agent starts; the agent cannot safely switch workspaces after launch.
What I Stopped Doing
Polling everything on a schedule
Scheduled collection looked convenient but created a report cache that was both expensive and confusing. The question was usually about a complete Apple period, while the scheduled job had often collected a partial or delayed one. On-demand jobs made the freshness boundary explicit.
Putting every event in one channel
A single channel made routing easy for the code and difficult for the human. Per-app topics let the channel supply context and gave different kinds of work somewhere appropriate to land.
Giving CI a Slack token
The runner could have posted directly to Slack. That would have spread a powerful workspace credential into another system and duplicated route lookup there. The callback contains a job ID, status, and report. The Worker remains the only component that knows how to talk to Slack.
Giving an assistant direct provider access
It is tempting to configure every provider credential in the assistant client. That makes the demo short and the trust boundary unclear. The assistant only receives a narrow authenticated tool contract, and the Worker returns bounded data rather than raw provider responses.
Treating tests as proof of the whole path
The Worker tests can pass while Slack rejects a manifest, the GitHub workflow uses a context in the wrong scope, or Apple's live response shape differs from a fixture. I now verify the provider callback, idempotent retry, and final same-thread behavior as a single path when a new boundary is introduced.
Security Rules I Keep
- Verify every inbound signature before parsing or acting on a request.
- Store event IDs, request IDs, and callback IDs as idempotency keys.
- Keep provider credentials in the system that needs them and nowhere else.
- Validate the app against an allowlisted registry before dispatching work.
- Keep Slack user permissions separate for read-only and mutating operations.
- Never put feedback text, reply email addresses, or provider credentials in ordinary logs or analytics.
- Treat a missing route, delayed Apple dataset, or provider schema change as an explicit state.
- Return controlled failure messages from background jobs so a Slack thread never stays on “working…” forever.
The architecture is intentionally modest. A Worker, a D1 database, a queue, a CI runner, and Slack are enough when each has a narrow job and the boundaries are made durable.
The Result
Indie Ops is not a dashboard that tries to replace App Store Connect, Mixpanel, RevenueCat, GitHub, or Xcode Cloud. It is the small layer between them that answers three questions:
- Where should this event go?
- Who asked for this work, and where should the result return?
- What state must survive a retry, a timeout, or a provider outage?
That framing has made the integration easier to extend. Feedback can enter through an iOS form without knowing Slack exists. An assistant can ask for metrics without receiving Apple's credentials. A release can begin from a Slack thread and still have Xcode Cloud remain the build authority.
The next part follows the first of those paths: from an iOS feedback form to a verified GitHub issue.