netapi

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 41 Imported by: 0

Documentation

Index

Constants

View Source
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.

View Source
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.

View Source
const ServiceType = "_samantha._tcp"

ServiceType is the mDNS/Bonjour type advertised by samantha serve. Clients browse for _samantha._tcp.local.

Variables

View Source
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.

View Source
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.

View Source
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

func IsTrustedServeIP(ip net.IP) bool

IsTrustedServeIP reports whether ip is safe to bind without --allow-public: loopback, RFC1918 private, link-local, or Tailscale/CGNAT overlay.

func RevokeTokens

func RevokeTokens(dir string) error

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

func WaitDiscovery(ctx context.Context, timeout time.Duration) ([]*zeroconf.ServiceEntry, error)

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:

  1. an optional local speaker (real Player, or nil to mute the host), and
  2. 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

type CertIdentity struct {
	DNSNames []string
	IPs      []net.IP
}

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

func StartDiscovery(bind string, fingerprint string, instance string) (*Discovery, error)

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.

func (*Discovery) Stop

func (d *Discovery) Stop()

Stop unregisters the mDNS service.

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

type PersonaBrain struct {
	Provider string `json:"provider"`
	Model    string `json:"model"`
}

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 New

func New(opts Options) *Server

func (*Server) Addr

func (s *Server) Addr() net.Addr

Addr returns the primary bound listener address once ListenAndServe has started.

func (*Server) Addrs

func (s *Server) Addrs() []net.Addr

Addrs returns every bound listener address (Bind first, then ExtraBinds) once ListenAndServe has started.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context) error

ListenAndServe serves until ctx is canceled, then shuts down gracefully. The bus subscription is detached on return — the pipeline outlives the server cleanly.

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

type TurnRunner interface {
	RunTurnTextMode(ctx context.Context, input string) error
}

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.

Directories

Path Synopsis
Package wire holds pure API/CLI value types shared by internal/netapi and cmd/samantha/cmd.
Package wire holds pure API/CLI value types shared by internal/netapi and cmd/samantha/cmd.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL