Documentation
¶
Index ¶
- Constants
- Variables
- func IsTrustedServeIP(ip net.IP) bool
- func RevokeTokens(dir string) error
- func WaitDiscovery(ctx context.Context, timeout time.Duration) ([]*zeroconf.ServiceEntry, error)
- func WriteIntentFile(dir string, req IntentRequest) (id, path string, err error)
- func WriteIntentFileWithID(dir, id string, req IntentRequest) (path string, created bool, err error)
- type AudioFanout
- type CertIdentity
- type Credentials
- func (c *Credentials) AcceptToken(presented string) bool
- func (c *Credentials) DeleteDevice(id string) (token string, ok bool, err error)
- func (c *Credentials) ExchangePairingCode(code string) (token string, err error)
- func (c *Credentials) ExchangePairingCodeForDevice(code, deviceName string) (token string, deviceID string, err error)
- func (c *Credentials) ListDevices() []DeviceInfo
- func (c *Credentials) MintDeviceToken(deviceName string) (*DeviceRecord, error)
- func (c *Credentials) RefreshPairingCode() (*PairingCode, error)
- func (c *Credentials) TouchToken(presented string)
- func (c *Credentials) VerifyRequest(r *http.Request) bool
- type DeviceInfo
- type DeviceRecord
- type Discovery
- type Dispatcher
- func (d *Dispatcher) ClearHistory() error
- func (d *Dispatcher) Interrupt()
- func (d *Dispatcher) ResumeSession(ctx context.Context, id string) error
- func (d *Dispatcher) Run(ctx context.Context)
- func (d *Dispatcher) SetPersona(ctx context.Context, id string, apply func(string) (PersonaAck, error)) (PersonaAck, error)
- func (d *Dispatcher) SubmitText(text string) error
- func (d *Dispatcher) SubmitVoice() error
- func (d *Dispatcher) TurnActive() bool
- func (d *Dispatcher) VoiceEnabled() bool
- type IntentContext
- type IntentRequest
- type IntentSinkConfig
- type Options
- type PairingCode
- type PairingCodeBanner
- type PersonaAck
- type PersonaBrain
- type PersonaSummary
- type PersonaTTS
- type Providers
- type ReadyBanner
- type RouteMeetingFunc
- type Server
- type SessionSummary
- type TurnRunner
- type VoiceTurnRunner
Constants ¶
const ( // ClientSetupURL is the free Tailscale admin page that enables trusted // HTTPS so strict browsers (notably mobile Safari) can use the mic. ClientSetupURL = "https://login.tailscale.com/admin/dns" LabelOpenOnClient = "Open on client:" LabelPairingCode = "Pairing code:" LabelNetwork = "Network:" LabelClientAccess = "Client access:" LabelClientSetup = "Client setup:" NetworkTailscale = "tailscale" NetworkLAN = "lan" AccessFull = "full" AccessLimited = "limited" )
Product-facing serve banner labels shared by `samantha serve` and the TUI. The TUI scrapes these exact prefixes from child stdout — keep them stable.
const ProtocolVersion = 3
ProtocolVersion is the integer serve reports in GET /v1/status and the machine-readable ready banner so clients can gate features against a stable contract version.
2 adds the /v1/meeting capture surface (PROTOCOL_DELTAS D6); 3 adds meeting history (GET /v1/meetings, bundle-id resolution), the note control action, and route_plan at start (D7). Clients should still prefer the per-feature capability flags in GET /v1/status over the version number, since serve can be built or configured without a feature: capture needs meetings == true and protocol_version >= 2, history needs protocol_version >= 3.
const ServiceType = "_samantha._tcp"
ServiceType is the mDNS/Bonjour type advertised by samantha serve. Clients browse for _samantha._tcp.local.
Variables ¶
var ErrBusy = errors.New("dispatcher queue is full")
ErrBusy reports a full dispatch queue — the pipeline is saturated and the client should retry rather than silently pile up turns.
var ErrSessionActive = errors.New("session is active")
ErrSessionActive is the sentinel Options.DeleteSession must return when asked to delete the session the pipeline is currently writing into. handleSessionDelete maps it to 409.
var ErrSessionNotFound = errors.New("session not found")
ErrSessionNotFound is the sentinel Options.DeleteSession must return when no session with the given id exists. handleSessionDelete maps it to 404; any other non-nil error is reported as 500 with its own message, so an unexpected failure (permissions, disk I/O) is never mislabeled as "not found" — the same distinction handleDeviceDelete already makes for device tokens, via a typed ok bool there and this sentinel here (Options.DeleteSession's single-error signature has no room for a second return value).
Functions ¶
func IsTrustedServeIP ¶
IsTrustedServeIP reports whether ip is safe to bind without --allow-public: loopback, RFC1918 private, link-local, or Tailscale/CGNAT overlay.
func RevokeTokens ¶
RevokeTokens deletes the long-lived bearer token file and all per-device tokens. A running server observes primary deletion, rejects further controls, closes active streams, and exits; the next serve start mints a fresh primary token and pairing code.
func WaitDiscovery ¶
WaitDiscovery is a test helper: browse briefly for samantha instances. Production clients use platform Bonjour APIs; this is only for tests.
func WriteIntentFile ¶
func WriteIntentFile(dir string, req IntentRequest) (id, path string, err error)
WriteIntentFile persists one intent into the file sink `POST /v1/intent` uses, under a fresh random id — each call files a new intent.
func WriteIntentFileWithID ¶
func WriteIntentFileWithID(dir, id string, req IntentRequest) (path string, created bool, err error)
WriteIntentFileWithID persists one intent under a caller-chosen id with atomic create-if-absent semantics. The complete payload is written and synced in the destination directory before a hard link publishes it under the final name, so a crash cannot leave a partial receipt at path. An existing valid receipt reports created=false; an invalid legacy/partial receipt is removed and recovered from the staged payload.
Types ¶
type AudioFanout ¶
type AudioFanout struct {
// contains filtered or unexported fields
}
AudioFanout is an audio.Engine that tees TTS PCM to:
- an optional local speaker (real Player, or nil to mute the host), and
- any WebSocket clients that opted into audio_output mode "stream".
It is the Phase 3 seam: pipeline.Player stays an Engine; no pipeline changes.
Local engine ownership: when ownLocal is true, Close() closes the local engine. When false, the caller (typically buildPipeline's cleanup) owns it.
func NewAudioFanout ¶
func NewAudioFanout(local audio.Engine) *AudioFanout
NewAudioFanout builds a fanout that does not own local (caller closes it). local may be nil (host speaker muted).
func NewOwnedAudioFanout ¶
func NewOwnedAudioFanout(local audio.Engine) *AudioFanout
NewOwnedAudioFanout builds a fanout that closes local on Close().
func (*AudioFanout) AttachHub ¶
func (a *AudioFanout) AttachHub(h *hub)
AttachHub wires the server's connection hub. Safe to call once before serve.
func (*AudioFanout) Close ¶
func (a *AudioFanout) Close() error
Close releases the local engine only when this fanout owns it.
func (*AudioFanout) IsPlaying ¶
func (a *AudioFanout) IsPlaying() bool
IsPlaying reports local speaker state (remote clients are not "playing" here).
func (*AudioFanout) PlayStream ¶
func (a *AudioFanout) PlayStream(ctx context.Context, stream *audio.PCMStream) (*audio.Playback, error)
PlayStream drains the TTS stream, pushes wire chunks to stream clients, and optionally forwards a teed copy to the local speaker. Matches the Engine contract: returns only after the first frames are ready (or the stream fails).
func (*AudioFanout) Stop ¶
func (a *AudioFanout) Stop()
Stop interrupts local playback when present. Remote stream clients stop only when the turn context is canceled (pipeline interrupt) — Stop alone does not tear down in-flight wire chunks.
type CertIdentity ¶
CertIdentity supplies optional DNS names and IPs embedded as SANs when a new self-signed certificate is minted. Existing cert files are never rewritten (fingerprint stays stable for TOFU clients).
type Credentials ¶
type Credentials struct {
Token string
Certificate tls.Certificate
Fingerprint string // SHA-256 of the leaf cert DER, hex
Dir string // credentials directory (token/cert files)
// TokenCreated reports whether this load generated a fresh token; the
// caller prints the token exactly once, at creation.
TokenCreated bool
// ExternalTLS is true when the certificate was loaded from caller-
// supplied paths (e.g. `tailscale cert` material) instead of the
// self-signed TOFU pair under the serve credentials dir.
ExternalTLS bool
// Pairing is a short-lived code clients exchange for Token over TLS.
// Regenerated each serve start; single-use once exchanged.
Pairing *PairingCode
// contains filtered or unexported fields
}
Credentials are the bearer token and TLS identity `serve` requires on every connection. Auth is mandatory — there is no "trusted LAN, skip auth" mode.
Token is the primary/shared bearer (serve/token). PROTOCOL_DELTAS D2 also mints per-device tokens under serve/tokens/; either form authenticates.
func LoadOrCreateCredentials ¶
func LoadOrCreateCredentials(dir string) (*Credentials, error)
LoadOrCreateCredentials loads the serve token and a self-signed TLS certificate from dir, generating any that are missing. Secrets are stored 0600 and never land in the YAML config.
func LoadOrCreateCredentialsWithIdentity ¶
func LoadOrCreateCredentialsWithIdentity(dir string, id CertIdentity) (*Credentials, error)
LoadOrCreateCredentialsWithIdentity is like LoadOrCreateCredentials but stamps MagicDNS / bind IPs into a newly generated self-signed cert so browsers opening the public URL see a matching name.
func LoadOrCreateCredentialsWithTLS ¶
func LoadOrCreateCredentialsWithTLS(dir, certPath, keyPath string) (*Credentials, error)
LoadOrCreateCredentialsWithTLS loads the serve token from dir and a caller-supplied TLS certificate/key pair (e.g. from `tailscale cert`). Both certPath and keyPath are required when either is set.
func RotateToken ¶
func RotateToken(dir string) (*Credentials, error)
RotateToken regenerates the long-lived token in place and returns the new credentials view (TLS material reloaded from disk). Existing clients with the old token are invalidated immediately.
func (*Credentials) AcceptToken ¶
func (c *Credentials) AcceptToken(presented string) bool
AcceptToken reports whether presented is the primary token or an active per-device token while the primary credentials file remains present.
func (*Credentials) DeleteDevice ¶
func (c *Credentials) DeleteDevice(id string) (token string, ok bool, err error)
DeleteDevice revokes one paired device. ok is false when the id is unknown. The returned token is the revoked bearer (for stream eviction).
func (*Credentials) ExchangePairingCode ¶
func (c *Credentials) ExchangePairingCode(code string) (token string, err error)
ExchangePairingCode validates a pairing code and returns the primary long-lived bearer token. The code is single-use; on success it is marked used. Prefer ExchangePairingCodeForDevice when the client sends a name.
func (*Credentials) ExchangePairingCodeForDevice ¶
func (c *Credentials) ExchangePairingCodeForDevice(code, deviceName string) (token string, deviceID string, err error)
ExchangePairingCodeForDevice validates a pairing code and, when deviceName is non-empty, mints a per-device token (D2). Empty deviceName returns the primary shared token for back-compat with older clients.
func (*Credentials) ListDevices ¶
func (c *Credentials) ListDevices() []DeviceInfo
ListDevices returns public metadata for all paired device tokens.
func (*Credentials) MintDeviceToken ¶
func (c *Credentials) MintDeviceToken(deviceName string) (*DeviceRecord, error)
MintDeviceToken creates a device token without pairing (tests / internal).
func (*Credentials) RefreshPairingCode ¶
func (c *Credentials) RefreshPairingCode() (*PairingCode, error)
RefreshPairingCode issues a new pairing code (e.g. after the previous one expires or is used).
func (*Credentials) TouchToken ¶
func (c *Credentials) TouchToken(presented string)
TouchToken updates last_seen for a device token (no-op for primary).
func (*Credentials) VerifyRequest ¶
func (c *Credentials) VerifyRequest(r *http.Request) bool
VerifyRequest checks the Authorization bearer header on every protected route. Browsers cannot set custom headers on WebSocket handshakes, so the stream endpoint alone also accepts ?token=. Accepts primary or D2 device tokens while the primary token file remains active.
type DeviceInfo ¶
type DeviceInfo struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
DeviceInfo is the public shape of GET /v1/devices (no secret token).
type DeviceRecord ¶
type DeviceRecord struct {
ID string `json:"id"`
Token string `json:"token"`
DeviceName string `json:"device_name"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
DeviceRecord is one paired device token stored under serve/tokens/. The raw token is kept on disk for auth; list responses omit it.
type Discovery ¶
type Discovery struct {
// contains filtered or unexported fields
}
Discovery advertises the serve endpoint on the local network via mDNS. It never carries the bearer token or pairing code — only the port and a cert fingerprint hint for TOFU comparison.
func StartDiscovery ¶
StartDiscovery registers a Bonjour service for the given TCP bind address. host is a human-readable instance name (defaults to hostname). Returns nil, nil for loopback because advertising a LAN service that is not reachable from the LAN would mislead clients.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher serializes pipeline access: pipeline turn methods assume one turn owns the pipeline at a time, so every remote control message funnels through one loop. Interrupt is the exception — it cancels the in-flight turn's context out-of-band, the same per-turn-context mechanism the conversation TUI uses (its D1 decision).
func NewDispatcher ¶
func NewDispatcher(runner TurnRunner, bus *events.Bus, clearHistory func(), resume func(id string) error) *Dispatcher
func (*Dispatcher) ClearHistory ¶
func (d *Dispatcher) ClearHistory() error
ClearHistory enqueues a history wipe, serialized against turns.
func (*Dispatcher) Interrupt ¶
func (d *Dispatcher) Interrupt()
Interrupt cancels the in-flight turn, if any. Unlike the other controls it does not queue — an interrupt behind the turn it targets is useless.
func (*Dispatcher) ResumeSession ¶
func (d *Dispatcher) ResumeSession(ctx context.Context, id string) error
ResumeSession loads a session behind any in-flight turn and reports the result. If ctx is canceled while waiting, apply skips the resume so a timed-out client does not swap session state later.
func (*Dispatcher) Run ¶
func (d *Dispatcher) Run(ctx context.Context)
Run processes control operations until ctx is canceled. It must run in exactly one goroutine.
func (*Dispatcher) SetPersona ¶
func (d *Dispatcher) SetPersona(ctx context.Context, id string, apply func(string) (PersonaAck, error)) (PersonaAck, error)
SetPersona serializes a live runtime switch behind any in-flight turn and waits until it has actually been applied. A client cannot receive a success ack and enqueue its next turn ahead of the persona change.
func (*Dispatcher) SubmitText ¶
func (d *Dispatcher) SubmitText(text string) error
SubmitText enqueues one text turn; ErrBusy when the queue is full.
func (*Dispatcher) SubmitVoice ¶
func (d *Dispatcher) SubmitVoice() error
SubmitVoice enqueues one remote-mic voice turn (STT → brain → TTS). The remote client must stream audio_input frames and voice_end on the ingress while this turn is active.
func (*Dispatcher) TurnActive ¶
func (d *Dispatcher) TurnActive() bool
TurnActive reports whether a turn is running right now.
func (*Dispatcher) VoiceEnabled ¶
func (d *Dispatcher) VoiceEnabled() bool
VoiceEnabled reports whether the runner can execute remote-mic turns.
type IntentContext ¶
type IntentContext struct {
MeetingID string `json:"meeting_id"`
OffsetMs int64 `json:"offset_ms"`
}
IntentContext is the meeting moment an idea was captured at. The offset is meeting-relative audio time — the same clock as bundle bookmarks — so the intent can point back into the transcript.
type IntentRequest ¶
type IntentRequest struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Concept string `json:"concept,omitempty"`
Campaign string `json:"campaign,omitempty"`
Source string `json:"source"`
CapturedAt string `json:"captured_at"`
// Context links a mid-meeting quick capture to its moment (plan §2.3).
// Optional and strictly additive: absent for plain captures, and the file
// sink emits it only when set, so existing consumers see identical JSON.
Context *IntentContext `json:"context,omitempty"`
}
IntentRequest is the JSON body of POST /v1/intent.
type IntentSinkConfig ¶
type IntentSinkConfig struct {
// Mode: "file" (default), "camp", or "webhook" (webhook deferred).
Mode string
// Dir is the file-mode destination root (default: credentials Dir/intents).
Dir string
// CampBin and CampaignRoot for mode=camp.
CampBin string
CampaignRoot string
}
IntentSinkConfig routes POST /v1/intent (PROTOCOL_DELTAS D3). Zero value uses file mode under Dir/intents.
type Options ¶
type Options struct {
// Bind is the host:port to listen on. The host must resolve to a
// loopback, private (RFC1918), link-local, or trusted overlay
// (Tailscale/CGNAT 100.64/10) address unless AllowPublic is set —
// serve refuses broader exposure by default.
Bind string
AllowPublic bool
// ExtraBinds are additional host:port addresses served by the same
// handler, TLS certificate, and auth. Each entry passes the same
// validateBind policy as Bind. Lets one serve reach loopback clients
// (the Mac app) and LAN clients (a paired phone) simultaneously.
// Duplicates of Bind or of earlier entries are ignored.
ExtraBinds []string
// SetPersona, when set, enables the set_persona control message. It is a
// callback rather than a config reference because netapi must not own
// persona resolution — serve builds it from the persona package.
//
// Semantics the implementation must honour: the change applies to
// SUBSEQUENT turns/sessions, never the one in flight. A session binds its
// identity for its whole life, so an ack claiming the current turn changed
// would be a lie the client acts on.
SetPersona func(id string) (PersonaAck, error)
// ListPersonas, when set, enables GET /v1/personas. It is a callback for
// the same reason SetPersona is: netapi must not own persona resolution.
// nil leaves the route unregistered so a serve without it answers 404
// rather than an empty list — a client feature-detects by status, the way
// Meetings gates /v1/meeting/*.
//
// The implementation must report the persona the runtime is using, not the
// persisted one: a set_persona changes the live config without writing
// config.yaml, and a list that disagreed with the running agent would be
// worse than no list.
ListPersonas func() ([]PersonaSummary, error)
Credentials *Credentials
Bus *events.Bus
Dispatcher *Dispatcher
ListSessions func() []SessionSummary
// DeleteSession, when set, enables DELETE /v1/sessions/{id}. nil leaves
// the route unregistered, so an older or limited serve 404s rather than
// pretending — the same gate Meetings uses. The implementation must
// return ErrSessionActive (never a plain string-matched error) when id
// is the session the pipeline is currently writing into; any other
// non-nil error is reported as 404 (not found).
DeleteSession func(id string) error
Providers Providers
// Audio, when set, is attached to the server hub so Phase 3 stream
// clients receive TTS audio_chunk envelopes from the pipeline player.
Audio *AudioFanout
// Ingress, when set, enables remote push-to-talk (Phase 4 / WI-62e19b).
// The serve pipeline's STT must already be wired to this same ingress.
Ingress *audio.Ingress
// OnListening is called once every TCP listener is bound, before Accept
// loops run. addrs are the actual bound addresses (primary first, deduped,
// real ports under :0 requests) — advertise these, never the requested
// bind strings.
OnListening func(addrs []net.Addr)
// IntentSink configures POST /v1/intent (PROTOCOL_DELTAS D3). Optional;
// defaults to file mode under credentials Dir/intents.
IntentSink IntentSinkConfig
// Meetings enables the /v1/meeting capture surface (PROTOCOL_DELTAS D6).
// Optional: when nil those routes are not registered and GET /v1/status
// reports the meetings capability as false.
Meetings *remote.Manager
// RouteMeeting files a finished meeting into a campaign
// (POST /v1/meeting/{id}/route → `camp idea notes import-meeting`).
// Injected by serve so netapi stays out of camp discovery and config;
// nil answers that route with 503.
RouteMeeting RouteMeetingFunc
}
Options configures a Server. All fields except AllowPublic are required.
type PairingCode ¶
type PairingCode struct {
Code string
ExpiresAt time.Time
// contains filtered or unexported fields
}
PairingCode is a single-use, short-lived code for LAN/tailnet device pairing without pasting the long bearer token by hand.
type PairingCodeBanner ¶
type PairingCodeBanner struct {
Event string `json:"event"` // always "pairing_code"
Code string `json:"code"`
ExpiresAt string `json:"expires_at"` // RFC3339
}
PairingCodeBanner is written to stdout whenever serve mints a pairing code (the Mac app renders a QR from it).
type PersonaAck ¶
type PersonaAck struct {
ID string
DisplayName string
// PromptHash identifies the assembled prompt, so a client can tell whether
// the model is seeing the document it expects.
//
// It is the first 12 hex characters of the sha256 of the assembled prompt
// text — a value that changes when the document changes. A document *name*
// here would be useless: it stays the same across every edit, which is
// exactly the question this field exists to answer. Empty means serve
// could not resolve the document.
PromptHash string
}
Server is the LAN-facing HTTPS + WebSocket surface around one pipeline. PersonaAck describes the persona a set_persona request selected.
type PersonaBrain ¶
PersonaBrain is a persona's effective model routing.
type PersonaSummary ¶
type PersonaSummary struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
// Active is the runtime persona, which a set_persona can move without
// anything being persisted.
Active bool `json:"active"`
// Builtin is additive beyond the ADR-004 shape: a list UI needs it to lock
// Delete on the shipped persona. Clients decode it as optional.
Builtin bool `json:"builtin"`
Brain PersonaBrain `json:"brain"`
TTS PersonaTTS `json:"tts"`
}
PersonaSummary is one row of GET /v1/personas.
Its brain/tts values are the persona's *effective* stack — a profile's empty field means "inherit the app default", and a list showing blanks would tell a user nothing about what the agent will sound like.
type PersonaTTS ¶
type PersonaTTS struct {
Provider string `json:"provider"`
Voice string `json:"voice"`
Tier string `json:"tier"`
}
PersonaTTS is a persona's effective speech settings. Tier is empty for providers that do not select a model tier.
type Providers ¶
type Providers struct {
Brain string `json:"brain"`
STT string `json:"stt"`
TTS string `json:"tts"`
}
Providers names the configured providers for GET /v1/status. Values are provider names only — never secrets.
type ReadyBanner ¶
type ReadyBanner struct {
Event string `json:"event"` // always "ready"
ProtocolVersion int `json:"protocol_version"`
URL string `json:"url"`
Port int `json:"port"`
Fingerprint string `json:"fingerprint"`
Token string `json:"token"`
MDNS bool `json:"mdns"`
Tailscale bool `json:"tailscale"`
PID int `json:"pid"`
// Binds lists every bound host:port, primary first. It is always present
// on a serve that reached OnListening, including single-bind serves, so a
// client picks a reachable address instead of inferring one from URL
// (ADR-008). URL stays the address remote clients should open.
Binds []string `json:"binds,omitempty"`
// ClientSetupURL is present only in limited client-access mode (a Tailscale
// serve that could not mint a trusted cert). Its presence means "limited";
// its absence means full access.
ClientSetupURL string `json:"client_setup_url,omitempty"`
}
ReadyBanner is the single JSON line `serve --banner-json` writes to stdout once the listener is bound. A supervising process reads this line to learn the URL, credentials, and fingerprint instead of scraping the human banner. Fields reflect what is real for the current mode (e.g. Tailscale) but the key set stays stable.
type RouteMeetingFunc ¶
type RouteMeetingFunc func(ctx context.Context, summary meetinglog.Summary, campaign, capture string) (remote.RouteReceipt, error)
RouteMeetingFunc routes a finished meeting's summary to a campaign. Implementations gate on camp CI0009 support (meeting.SupportsImportMeeting) before filing when capture resolves to the meetings importer.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
func (*Server) Addr ¶
Addr returns the primary bound listener address once ListenAndServe has started.
type SessionSummary ¶
type SessionSummary = wire.SessionSummary
SessionSummary is one row of GET /v1/sessions. It aliases wire.SessionSummary so cmd/samantha/cmd can depend on the shape without pulling in netapi's cgo-heavy transitive imports under -tags integration.
type TurnRunner ¶
TurnRunner is the slice of pipeline.Pipeline serve drives. Text turns always work; voice turns require VoiceTurnRunner (STT + remote ingress).
type VoiceTurnRunner ¶
type VoiceTurnRunner interface {
TurnRunner
RunTurn(ctx context.Context) (string, error)
}
VoiceTurnRunner is optional: when the serve pipeline has STT wired to a remote audio ingress, voice push-to-talk uses RunTurn.