How I built a workspace where AI agents ship an Expo app while every decision, token, and artifact stays inspectable
The problem
Multi-agent AI systems have a trust problem. You give a fleet of agents a product brief, they churn for two minutes, and an app falls out. What happened in between? Which agent decided you needed bottom tabs instead of a drawer? Why did the profile screen take three times longer than the settings screen? Where did your inference budget actually go?
Most agent frameworks answer this with logs. Kairo answers it with a full observability product: the agents are the workload, and the workspace is the APM.
Kairo is a collaborative workspace, built entirely in Expo and React Native, where specialized AI agents design and ship an Expo mobile app from a product brief. The pipeline itself is ordinary. What makes it interesting is that every step is inspectable across nine surfaces: an agent canvas, a dependency DAG, an event timeline, an artifact graph, a decision explorer, shared memory, a metrics dashboard, time-travel replay, and a live phone preview that goes live before the build finishes.
Architecture overview
┌──────────────────────────────────────────────┐
│ Kairo Workspace (Expo) │
│ │
Product brief │ ┌──────────┐ ┌───────────────────────┐ │
─────────────▶ │ │ Planner │────▶ │ AppPlan │ │
│ │(mock/LLM)│ │ navigation + screens │ │
│ └──────────┘ └──────────┬────────────┘ │
│ │ │
│ ┌──────────────────────▼───────────┐ │
│ │ Agent Pipeline (DAG) │ │
│ │ architecture ─▶ designSystem │ │
│ │ │ │ │ │
│ │ └──▶ primaryScreen ──▶ QR │ │
│ │ │ │ │
│ │ parallelScreens (N agents)│ │
│ └──────────┬───────────────────────┘ │
│ │ TraceEvents / Decisions │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 9 Observability Surfaces │ │
│ │ Canvas · DAG · Timeline · Artifacts │ │
│ │ Decisions · Memory · Metrics · Replay │ │
│ │ Live Preview + QR │ │
│ └─────────────────────────────────────────┘ │
└───────┬───────────────┬──────────────┬───────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌─────────────┐
│ agentOS │ │ Laminar │ │ mem0 │
│ bridge :7420 │ │ OTLP spans │ │ agent memory│
│ VMs, LAN IP, │ │ (telemetry │ │ (optional) │
│ preview state│ │ backend) │ │ │
└──────────────┘ └────────────┘ └─────────────┘
Three design decisions shape everything:
-
Events are the source of truth. Every action any agent takes emits a typed
TraceEvent. All nine surfaces are projections of the same event stream. - Kairo is the UI, backends are storage. Laminar receives full OTLP spans, but its UI is never shown. mem0 stores memory, but Kairo renders it.
- The pipeline works offline. Every external dependency (LLM, agentOS, mem0, Laminar) has a high-fidelity mock fallback, so the demo never dies on stage.
The event model
The entire system hangs off one union type:
export type TraceEventType =
| 'pipeline.start' | 'pipeline.complete' | 'plan.ready'
| 'agent.queued' | 'agent.waiting' | 'agent.blocked'
| 'agent.started' | 'agent.step' | 'agent.reasoning'
| 'agent.decision' | 'agent.retry' | 'agent.complete' | 'agent.error'
| 'artifact.created' | 'artifact.updated'
| 'memory.added' | 'memory.searched'
| 'handoff' | 'export' | 'preview.ready';
export type TraceEvent = {
id: string;
ts: number;
type: TraceEventType;
agentId?: AgentId;
title: string;
detail?: string;
meta?: Record<string, string | number | boolean | null>;
};
Because everything is a timestamped event, features that usually require dedicated infrastructure become derivations:
-
Time-travel replay is just filtering the event array to
ts <= cursorand re-deriving agent and artifact state at that instant. - The metrics dashboard computes parallelism, critical path, and wait times from event timestamps alone.
- Sparklines on the metric cards are cumulative bucket counts over the event stream:
export function cumulativeSeries(timestamps: number[], buckets = 24): number[] {
const sorted = [...timestamps].sort((a, b) => a - b);
const start = sorted[0];
const span = Math.max(1, sorted[sorted.length - 1] - start);
const out = new Array(buckets).fill(0);
for (const ts of sorted) {
const i = Math.min(buckets - 1, Math.floor(((ts - start) / span) * buckets));
out[i] += 1;
}
let acc = 0;
return out.map((n) => (acc += n));
}
Clicking any metric card jumps to the timeline pre-filtered by event-type prefix, so "Retries: 3" is one tap away from the three agent.retry events that produced it. The filter is just a list of prefixes in context state:
export type TimelineFilter = { label: string; types: string[] };
// Metric card tap:
setTimelineFilter({ label: 'Retries', types: ['agent.retry', 'agent.error'] });
setView('timeline');
// Timeline render:
const events = timelineFilter
? allEvents.filter((e) => timelineFilter.types.some((t) => e.type.startsWith(t)))
: allEvents;
Decisions as first-class records
Logs tell you what happened. Kairo also records why. Every significant choice an agent makes lands as a DecisionRecord with alternatives and confidence:
export type DecisionRecord = {
id: string;
agentId: AgentId;
ts: number;
title: string;
decision: string; // "Bottom Tabs"
reason: string; // "Detected 5 primary destinations"
alternatives: string[]; // ["Drawer", "Stack"]
confidence: number; // 0.93
category: string;
};
This is the single most demo-effective feature. Developers do not want to read a reasoning transcript; they want to see "chose X over Y and Z, 93% confident, because of this" in one card.
The live preview: hardest 5% of the project
Phase 11's promise is that the moment the first screen ships, a QR appears, and you watch the app evolve on your phone while agents are still building. Making that QR actually work from a browser-based workspace taught me more about networking than the rest of the project combined:
-
localhost is a lie. The workspace runs at
localhost:8081, so every naive URL source producesexp://localhost:8081, which on a phone points at the phone. The fix is a sidecar endpoint that reports the dev machine's LAN IP, filtering out link-local169.254.xaddresses and VPN interfaces (utun,awdl,bridge) that phones cannot reach, and preferring private ranges. -
Port squatting is real. Another dev tool on my machine was sitting on the bridge's default port and answering health checks, so Kairo happily talked to the wrong server. The client now verifies identity (
service: "kairo-agentos"in the health payload) before trusting any endpoint, and the server hops ports when its default is taken. -
Store rollouts lag SDKs. Expo SDK 57 went stable days ago; the Expo Go build that supports it is not in stores yet. Since Kairo renders on react-native-web anyway, the QR defaults to a plain browser URL (
http://LAN_IP:8081/preview) that any camera app opens, with Expo Go mode behind a toggle.
The preview route itself fetches the pipeline's shared app plan from the bridge, so a phone that scans mid-build sees real content immediately and new tabs appear as agents finish screens.
The agentOS bridge
A small Express sidecar (agentos/server.ts, ~500 lines) provides per-agent VM workspaces, the LAN IP endpoint, cross-device preview state, and project export:
app.post('/api/kairo/state', (req, res) => {
const { appPlan, projectName } = req.body;
kairoPreviewState = { appPlan, projectName, updatedAt: Date.now() };
res.json({ ok: true });
});
The export endpoint is the payoff: it takes the final AppPlan and writes a complete standalone Expo project to disk, with expo-router tabs generated per screen, the shared DynamicScreen renderer, and theme tokens copied over. The generated app runs with npm install && npx expo start.
Telemetry without a second UI
Laminar integration follows one rule from the spec: never expose Laminar's UI. Every agent becomes a span, every action an event, every artifact span metadata, exported over OTLP/HTTP in the background. Kairo stays the visualization layer; Laminar stores the history. This separation is worth copying: your observability product should not be a login page to someone else's.
Lessons
- Model events first. Nine surfaces cost far less than you would expect when they are all projections of one stream.
- Record decisions, not just actions. Alternatives plus confidence is what makes agents feel inspectable rather than merely logged.
- Mock every external dependency well. The mock pipeline is not a stub; it is a scripted performance that exercises every surface.
- Budget real time for the physical-world edges. QR codes, LAN IPs, port conflicts, and app store lag consumed more debugging time than any agent logic.
The stack: Expo SDK 57, React 19, React Native 0.86, Reanimated 4, react-native-svg for the DAG and sparklines, an Express sidecar, Laminar for OTLP, mem0 for memory. MIT licensed.
Code & more: https://www.dailybuild.xyz/project/197-kairo






Top comments (0)