Case Study

An iOS client where the agent draws the UI

Ten weeks as the only iOS engineer on a team chat product built around AI teammates. People and agents share one conversation, so the app has to render whatever an agent decides to put there — while it is still being written.

Engagement
Contract, sole iOS engineer
Period
Jun – Sep 2026
Client
Venture-backed team chat startup
Stack
Swift, SwiftUI, UIKit, iOS 26
10
weeks, one engineer
~20
TestFlight releases
44%
faster UI test suite

The brief

01

The product was already live on web and desktop with people using it daily. iOS existed as an internal build nobody outside the company had run. I came in through a warm introduction on a two-week paid trial, and the trial turned into the engagement. One mandate: make the iOS app something you could put in front of a customer.

The working arrangement was the interesting part. iOS changes shipped without review; anything touching the server went through a pull request. Fifteen minutes with the CTO a day, no standups, no ticket queue. Scope arrived as a conversation and left as a TestFlight build.

The hard part

02

In an ordinary chat app the client knows every row type at compile time. Here it does not. An agent working in a thread streams its output back into the conversation and can decide, mid-task, that it needs something from the person watching: pick one of these, confirm this, fill this in. That arrives as an interactive card which has to render inline, take an answer, submit it, and then settle into a static record of what was answered.

Three things make that hard on a phone rather than merely fiddly.

Rows change height while you look at them

Streamed text grows, cards expand, blocks collapse. The list has to stay anchored to the newest message without shoving content out from under a thumb that is already dragging.

The card is state, not a view

A prompt can be answered, abandoned, superseded by the agent finishing, or delivered twice on a reconnect. Every one of those was a bug before it was a behaviour.

The keyboard is a third participant

A compose field, an interactive card and a scroll view all negotiating the same bottom inset is where most of the visible bugs lived.

The message list ended up as a y-inverted UICollectionView behind a UIViewRepresentable, because SwiftUI's scrolling stack does not hand you scroll geometry. That rewrite has its own write-up.

When SwiftUI stops being enough

What shipped

03

The conversation surface

  • — Message list rebuilt on a y-inverted collection view behind a UIViewRepresentable: stable scroll anchoring across pagination, no duplicate page loads, smooth under streaming updates
  • — App-wide keyboard tracking so the list, the composer and the floating panels move as one on both drag- and tap-to-dismiss
  • — Interactive agent cards rendered inline in the message list rather than hijacking the composer, with the networking models and submission path to match
  • — A hand-built glass edge blur for the inverted list, since flipping the list detaches the system effect
  • — Full-screen image viewer, swipe-to-dismiss file preview, and attachment caching that survives a credential refresh

Agent and conversation features

  • — Server-driven slash commands replacing a hardcoded client list, so mobile follows the web app without shipping a release
  • — Mention picker scoped to the agents actually present in the conversation
  • — Message drafts that persist across every composer, sync to the server, and survive going offline
  • — Expanded markdown rendering: code blocks, tables, nested lists, checkboxes

Reliability

  • — Send path hardened against loss and duplication: a failed send keeps its text with inline retry, and retrying stops producing duplicate messages and duplicate agent replies
  • — Paged, size-limited backfill of messages missed while disconnected, and a reconnect that no longer overwrites freshly restored cache
  • — Read and unread state corrected across cold launch, notification taps and offline use
  • — Push-notification deep links routed to the right tab and thread on cold start

Release engineering

  • — Code signing set up end to end, team identifier kept out of version control, release builds on manual signing backed by fastlane match
  • — CI branching split so routine commits stop triggering release builds and notifying testers
  • — Crash, error and structured log reporting, gated to release builds and preceded by a privacy review that removed a response-body leak, redacted socket payloads and disabled screen-title breadcrumbs before rollout
  • — A server-driven feature-flag layer so a backend migration could ship dark, be verified against the old path per client, and then be torn down
  • — UI test suite runtime cut 44% (190s to 107s on a benchmark slice) by replacing the test framework’s one-second polling floor across ninety-odd call sites

Leaving it clean

04

The engagement closed in September. On the last day I audited the four outstanding feature branches for completeness and cross-branch dependencies, wrote a branch-by-branch handover covering the flag layer, the migration in flight, the known bugs, the CI workflows and the remaining App Store blockers, and landed every outstanding commit in the client's repository — deliberately leaving the release branch untouched so that nothing shipped to testers unannounced.

How work is handed back is part of the work. Every invoice across the engagement was paid without a query.

The client is unnamed and the detail here is deliberately general. Work product and product internals belong to them; what is described is my own contribution and the engineering decisions behind it.