Build a client
The shortest credible Protocol client, worked end to end: an event viewer — it authenticates, handshakes, and renders one session's live event stream. Under 160 lines of Go (a fifth of them the doc comment), stdlib only: no Harbor import, no SDK, no generated code. That is the point of this page — the Protocol is the integration surface, and net/http + encoding/json is a complete toolkit for it.
Methods demonstrated:
runtime.info,events.subscribe
The full source lives in the repository at examples/protocol-clients/event-viewer/ and is compile-gated by the project's preflight smoke — a wire-type or route change that breaks this client fails the build, so the listing below cannot rot.
Run it first
harbor dev & # any Harbor Runtime works
go run ./examples/protocol-clients/event-viewerReal output against a dev Runtime running one short task:
runtime harbor-dev-192.168.1.7 (build v0.0.0-dev) speaks Protocol 0.1.0
tailing session "event-viewer" — Ctrl-C to stop
5 session.opened run= {"SessionID":"event-viewer","OpenedAt":1781139747555658000}
6 task.spawned run= {"TaskID":"01KTT37CQ3317D7DH8QRZC4YRZ","Kind":"foreground","ParentTaskID":"","Priority":0,"IdempotencyKey":""}
7 task.started run= {"TaskID":"01KTT37CQ3317D7DH8QRZC4YRZ","PriorState":"pending"}
10 llm.completion.chunk run=01KTT37CQ3317D7DH8QRZC4YRZ {…,"Delta":"mock:User ","Done":false,"Kind":"content",…}
15 planner.decision run=01KTT37CQ3317D7DH8QRZC4YRZ {…,"DecisionKind":"Finish","Tool":"",…}
16 task.completed run= {"TaskID":"01KTT37CQ3317D7DH8QRZC4YRZ"}The three moves
Every Protocol client makes the same three moves the event viewer makes; everything richer (a dashboard, a TUI, an intervention inbox) is these three plus rendering.
1. Get a token
A token authenticates who — (tenant, user) plus scopes; the conversation is chosen per-request via the X-Harbor-Session header (auth & identity). The event viewer takes HARBOR_TOKEN from the environment, falling back to the loopback-only dev bootstrap for the harbor dev case:
token := os.Getenv("HARBOR_TOKEN")
if token == "" {
var boot struct {
Token string `json:"token"`
}
mustPost(baseURL+"/v1/dev/bootstrap.json", "", "", []byte(`{}`), &boot)
token = boot.Token
}In production there is no bootstrap endpoint — your identity provider mints the JWT (or you self-issue with harbor token). The full setup — OIDC app registration, the claim shape harbor serve verifies, the iss/aud exact-match contract, and both attach on-ramps — is the production identity setup guide.
2. Handshake: runtime.info
Pin what you were built against; tolerate what you don't know (versioning & compatibility):
var info struct {
InstanceID string `json:"instance_id"`
BuildVersion string `json:"build_version"`
ProtocolVersion string `json:"protocol_version"`
Capabilities []string `json:"capabilities"`
}
mustPost(baseURL+"/v1/control/runtime.info", token, session, []byte(`{"identity":{}}`), &info)
const builtAgainstMajor = "0" // Protocol 0.x — same major ⇒ compatible
if strings.Split(info.ProtocolVersion, ".")[0] != builtAgainstMajor {
fail("protocol major version skew: runtime speaks %s, this client was built against %s.x",
info.ProtocolVersion, builtAgainstMajor)
}
capable := false
for _, c := range info.Capabilities {
capable = capable || c == "events_subscribe"
}
if !capable {
fail("this runtime does not advertise the events_subscribe capability")
}Note what the struct does NOT declare: display_name, uptime_seconds, and any field a future minor adds. A partial decode that ignores unknown fields is the unknown-field tolerance rule, implemented for free by encoding/json.
3. Tail the stream
SSE is line-oriented; a bufio.Scanner is a complete SSE parser for this wire. Last-Event-ID: 0 replays the session's retained history before live-tailing (streaming semantics):
req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/events", nil)
if err != nil {
fail("build events request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Harbor-Session", session)
req.Header.Set("Last-Event-ID", "0")
resp, err := http.DefaultClient.Do(req)
// … status check …
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var eventType, id string
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event: "):
eventType = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "id: "):
id = strings.TrimPrefix(line, "id: ")
case strings.HasPrefix(line, "data: "):
var frame struct {
Run string `json:"run"`
Payload json.RawMessage `json:"payload"`
}
data := strings.TrimPrefix(line, "data: ")
if err := json.Unmarshal([]byte(data), &frame); err != nil {
fail("malformed data frame: %v", err) // a broken frame is a wire bug — fail loud
}
fmt.Printf("%6s %-28s run=%-26s %s\n", id, eventType, frame.Run, frame.Payload)
}
}The viewer keeps payload as json.RawMessage and prints it — it consumes the stream generically, which is why it survives event-catalog growth untouched. A client that branches on types decodes per-type shapes from the events reference inside that data: case.
What the teaching artifact deliberately leaves out, and what a production client adds: reconnect (remember the largest id:, reopen with Last-Event-ID on drop), re-snapshot on bus.dropped / stream.replay_unavailable, and a steering surface (start, cancel, the intervention verbs — all single POSTs; see task control and the pause model). The closing pseudocode in streaming semantics is the upgrade path.
The doors up
When the event viewer stops being enough, three reference points show the full pattern — read them, vendor from them:
- The generated external-client TypeScript types module —
examples/protocol-clients/event-viewer-ts/harbor-protocol.gen.tsin the Harbor repo, emitted bycmd/harbor-protocol-ts-typesfrom the canonical Go single sources and drift-gated bymake protocol-ts-types-gen-check. It is a self-contained, dependency-free module of TypeScriptinterfaces for every wire type plusHarborMethod/HarborErrorCode/HarborEventTypestring-union types and the pinnedPROTOCOL_VERSION/WIRE_SURFACE_DIGESTconstants — types only, no client logic. For a TypeScript client this is the door to start at: copy the one generated file and write your transport against it. The workedevent-viewer-tsclient consumes it against the dev runtime. - The Console's TypeScript client module —
web/console/src/lib/protocol.tsin the Harbor repo: every wire type plus a typedHarborClient, the module the bundled Console runs on. It is hand-maintained in lockstep with the Go wire types and kept honest by the Console's own CI — vendor it when you want the typed transport, not just the types. The external-client types generator above partially retired the long-standing TS-generation deferral (D-269); the FULL Console-protocol.tsgenerator stays deferred (D-132). The generated ground truth for the types alone is the types reference, regenerated from the Go sources on every wire change. - The Console itself — the canonical full client: chat, fleet observation, the intervention queue, artifacts, topology. Everything it renders arrives through the same surface documented in this track; it holds no privileged access (RFC §5.1). When you wonder "how should a client consume X?", the Console's consumption of X is the worked answer.
The closing door: prove compatibility
When your client (or your own Runtime-side implementation of this contract) needs a compatibility claim stronger than "it works on my machine", the conformance certification page documents the suite Harbor itself is gated on, and exactly what a pass claims.