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.
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.
| # | Category | Entry points |
|---|---|---|
| 1 | Start a run | tracker.Run(ctx, source, cfg), or NewEngineWithContext / NewEngineFromGraph → Engine.Run for a pre-parsed graph. |
| 2 | Answer human gates | Config.Interviewer — anything implementing the interviewer family. The key seam. |
| 3 | Observe progress | Config.EventHandler (pipeline lifecycle), AgentEvents (per-tool-call), LLMTrace (raw tokens). |
| 4 | Control a run | Cancel the run ctx (or RunManager.Cancel(key)); resume via Config.CheckpointDir / ResumeRunID. |
| 5 | Deliver & inspect | tracker.Result, plus read-only ListRuns / Audit / Diagnose / Simulate / ExportBundle. |
| 6 | Resolve what to run | ResolveSource (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.
| Interface | Adds | Gate modes |
|---|---|---|
Interviewer | Ask(prompt, choices, def) | choice, yes/no |
FreeformInterviewer | AskFreeform(prompt) | open-ended reply |
LabeledFreeformInterviewer | AskFreeformWithLabels(…) | labels + freeform escape |
InterviewInterviewer | AskInterview(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.
Start(ctx, key, source, cfg)— launches a run in its own goroutine, keyed bykey(e.g. a Slackthread_ts). An atomic claim guards the active-key and an optional concurrency cap (WithMaxConcurrent→ErrAtCapacity/ErrRunKeyActive).ManagedRun— exposesState/Done/Result/RunID; the manager exposesGet/List/Cancel/Forget.- Isolation — a distinct
WorkingDirper run (WithWorkDirBase), no shared state, and a:0webhook port by default so two webhook-gated runs never collide.
The transports as instances
| Transport | Interviewer | Progress | Concurrency |
|---|---|---|---|
| TUI | BubbleteaInterviewer via Config.Interviewer | EventHandler/AgentEvents → prog.Send; shares Config.TokenTracker | one run per process |
Slack (trackerbot) | ThreadInterviewer (thread gates) | notifier filters events → thread | RunManager, one run per thread |
CLI REPL (trackerchat) | ThreadInterviewer (inline text gates) | messages printed to the terminal | one conversation per process |
| web / mobile | implement handlers.Interviewer | subscribe to the streams | RunManager |
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.
Implement the interviewer
Implement
handlers.Interviewer(plus the richer extensions you support) to present gates in your medium; pass it asConfig.Interviewer.Render progress
Provide a
PipelineEventHandler(and optionallyAgentEvents/LLMTrace) that renders progress; pass them viaConfig.Scale concurrency
For many concurrent runs, hold a
RunManagerkeyed by your session id, withWithWorkDirBasefor isolation.Resolve & deliver
Map requests to a workflow with
ResolveSource/Workflows; deliver withResult+Diagnose/ExportBundle.Prove it with the conformance suite
Run
transport/conformanceRunInterviewerSuitefrom 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 suiteSlackInterviewerpasses.
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.