Transports

A transport is a front-end that drives runs and relays them to a human. The terminal TUI, the Slack bot, a future web or mobile app — every one is a first-class peer on the same small library boundary. None is privileged.

The boundary

The core engine knows nothing about LLMs, subprocesses, terminals, or Slack. A front-end plugs in through a small, deliberate library surface — tracker.Config → Engine, an Interviewer seam for human gates, the event streams, and RunManager for concurrency. What the library can do and what the product can do are the same set.

The tracker CLI proves it: since the CLI → library unification, the TUI runs on nothing but this boundary — it is just another Config consumer.

flowchart TB subgraph transports["Transports (peers)"] tui["TUI
BubbleteaInterviewer"] slack["Slack · trackerbot
ThreadInterviewer"] repl["CLI REPL · trackerchat
ThreadInterviewer"] web["web / mobile
(future)"] end subgraph boundary["The boundary"] cfg["tracker.Config
NewEngineFromGraph / Run"] rm["RunManager
N concurrent runs"] iv["handlers.Interviewer
gate answering"] ev["PipelineEvent + agent.Event
progress stream"] end subgraph core["Core (UI-agnostic)"] eng["pipeline.Engine"] reg["HandlerRegistry"] end transports --> cfg transports --> rm transports -->|implement| iv transports -->|subscribe| ev cfg --> eng rm --> cfg iv --> reg eng --> ev

Full contract: docs/architecture/transport-boundary.md.

Six interactions

A transport is fully described by six interaction categories, all composed from existing public types.

#CategoryEntry points
1Start a runtracker.Run(ctx, source, cfg), or NewEngineWithContext / NewEngineFromGraphEngine.Run for a pre-parsed graph.
2Answer human gatesConfig.Interviewer — anything implementing the interviewer family. The key seam.
3Observe progressConfig.EventHandler (pipeline lifecycle), AgentEvents (per-tool-call), LLMTrace (raw tokens).
4Control a runCancel the run ctx (or RunManager.Cancel(key)); resume via Config.CheckpointDir / ResumeRunID.
5Deliver & inspecttracker.Result, plus read-only ListRuns / Audit / Diagnose / Simulate / ExportBundle.
6Resolve what to runResolveSource (path → local .dip → built-in catalog), the Workflows() catalog, NewLLMClient for free-text classification.

The interviewer seam

Config.Interviewer accepts anything implementing the interviewer family in pipeline/handlers/human.go. The human handler upgrades to the richest supported mode via a type assertion — so an interactive transport uses the convenience API rather than dropping to the handlers layer.

InterfaceAddsGate modes
InterviewerAsk(prompt, choices, def)choice, yes/no
FreeformInterviewerAskFreeform(prompt)open-ended reply
LabeledFreeformInterviewerAskFreeformWithLabels(…)labels + freeform escape
InterviewInterviewerAskInterview(questions, prev)structured multi-field form

Optional side-interfaces, checked by assertion: Actor() (override auditing), Cancel() (torn down on Engine.Close), SetPipelineContext(ctx) (a run cancellation unblocks a waiting gate). Reference implementations prove the seam is transport-neutral: ConsoleInterviewer, BubbleteaInterviewer, WebhookInterviewer, the autopilot interviewers, and SlackInterviewer.

Invariants a transport inherits

These hold in the core, regardless of transport. A front-end cannot violate them, and gets them for free. Your job is presentation and identity — not re-implementing safety and durability.

Exactly one terminal event

The top-level engine emits exactly one event carrying TerminalStatus — including panic and invariant-error exits, which a backstop covers. Your run-finished signal never goes missing, so a Slack thread never hangs waiting for “done”.

Panic containment

A handler panic on the run goroutine becomes a fail terminal event, never a crashed host. A RunManager driving many runs survives one bad run — a single panicking pipeline can’t take down every other thread.

Per-run isolation

Distinct WorkingDir per run, no shared mutable state, per-run webhook callback port. N concurrent runs never collide on the filesystem or on ports.

Durable resume

Checkpoints are written atomically (temp + rename) and keyed deterministically. A process crash — even one mid-write — resumes from the last completed node; a crash at a human gate resumes at the gate without re-running the completed upstream node.

RunManager — N concurrent runs

A service (Slack, web) drives many runs at once from one process. RunManager owns them, keyed by a caller-chosen external id.

The transports as instances

TransportInterviewerProgressConcurrency
TUIBubbleteaInterviewer via Config.InterviewerEventHandler/AgentEventsprog.Send; shares Config.TokenTrackerone run per process
Slack (trackerbot)ThreadInterviewer (thread gates)notifier filters events → threadRunManager, one run per thread
CLI REPL (trackerchat)ThreadInterviewer (inline text gates)messages printed to the terminalone conversation per process
web / mobileimplement handlers.Interviewersubscribe to the streamsRunManager

The reference transport: Slack

cmd/trackerbot is the proof the boundary works for a non-terminal front-end — a pure consumer, no engine changes. It supplies a SlackInterviewer for gates, filters the event stream into thread notifications, and keys a RunManager on thread_ts for one isolated run per thread. Mention it, watch the run happen in the thread, get the result back. Everything on this page — the interviewer seam, the streams, per-run isolation, durable resume — it exercises for real.

Get started with the Slack bot — the full guide: Slack app setup, install, commands, gates, security & cost, configuration, and troubleshooting.

Build a new transport

Five steps take a web app, a mobile app, or a second bot from nothing to a proven front-end.

  1. Implement the interviewer

    Implement handlers.Interviewer (plus the richer extensions you support) to present gates in your medium; pass it as Config.Interviewer.

  2. Render progress

    Provide a PipelineEventHandler (and optionally AgentEvents / LLMTrace) that renders progress; pass them via Config.

  3. Scale concurrency

    For many concurrent runs, hold a RunManager keyed by your session id, with WithWorkDirBase for isolation.

  4. Resolve & deliver

    Map requests to a workflow with ResolveSource / Workflows; deliver with Result + Diagnose / ExportBundle.

  5. Prove it with the conformance suite

    Run transport/conformance RunInterviewerSuite from a test. It drives your interviewer through every gate mode and asserts cancellation unblocks a waiting gate — the executable definition of a correct interviewer, the same suite SlackInterviewer passes.

Nothing above touches the engine — the boundary is the whole contract. The safety and durability invariants are enforced in the core, so a new transport inherits them and must not re-implement them.

Living proof: cmd/trackerchat — a terminal REPL — is a second consumer built from the same transport/chatops core the Slack bot uses. It adds only a terminal ThreadUI (print messages, render gates as a numbered list) and a stdin loop that routes each line to the shared Runner. Because that core carries the commands, gating, estimate/confirm, budget-bump, and steer, the REPL inherits them all — the transport-specific code is I/O only, and it’s exercised end-to-end in tests with no external service.