profiles

package
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: Sep 21, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package profiles is the bot's identity and its playbooks: what a playbook is, how one is validated, and how the directory on disk (PODIUM_AGENT_PROFILE_DIR) is loaded.

A playbook declares which image a turn runs, which prompt it is given, which tools it may use and which stored secrets it names. Naming a secret here is not a privilege: a task spec names secrets the same way and nothing authorises which names a caller may use — see docs/security.md. What a playbook file does is decide what THIS bot hands a turn.

Index

Constants

View Source
const (
	AgentClaude = "claude"
	AgentGrok   = "grok"
	AgentOpenAI = "openai"
)

An agent backend is the harness a turn runs on, together with the credential it spends. A playbook names one; the profile supplies the default.

Every backend in this file is the SAME runtime image driving the SAME harness. What changes is where the harness sends its requests and which credential it spends. That is why this is a field and not a second image.

View Source
const (
	ProviderAnthropic = "anthropic"
	ProviderXAI       = "xai"
	ProviderOpenAI    = "openai"
)

The providers a backend spends a credential from. A provider is a set of credentials and an endpoint; a backend is a harness pointed at one.

View Source
const (
	EffortLow    = "low"
	EffortMedium = "medium"
	EffortHigh   = "high"
	EffortXHigh  = "xhigh"
	EffortMax    = "max"
)

The reasoning-effort levels. They are the Claude Agent SDK's own vocabulary, which xAI's reasoning_effort shares for its first four; `max` is Anthropic-only and the catalogue below is what says so per model.

View Source
const (
	FieldDisplayName     = "display_name"
	FieldModel           = "model"
	FieldAgent           = "agent"
	FieldEffort          = "effort"
	FieldDefaultPlaybook = "default_playbook"
)

The profile.yaml keys Overrides can supply, as Fields reports them.

View Source
const (
	MinPriority = -1000
	MaxPriority = 1000
)

The range a playbook's priority may sit in. The bound is a guard against a typo rather than a scale with meaning: the queue is sorted, so only the ORDER of these numbers matters and a thousand steps either side of the default is more than any fleet can distinguish.

View Source
const (
	DefaultMaxTurns = 50
	DefaultTimeout  = spec.Duration(30 * 60 * 1e9)
)

Defaults for a playbook.

View Source
const AnthropicKeyEnv = "ANTHROPIC_API_KEY"

AnthropicKeyEnv is where that secret lands in the task container.

View Source
const AnthropicKeySecret = "podium.agent.anthropic_api_key"

AnthropicKeySecret is the reserved secret the conductor attaches to a Claude turn itself. A playbook may not name it: the whole point of the reservation is that no playbook file decides whether the bot can talk to the model.

View Source
const BriefEnv = "PODIUM_AGENT_TURN"

BriefEnv is the env var the brief travels in. A playbook's env: may not set it.

View Source
const DefaultAgent = AgentClaude

DefaultAgent is what a profile that names no agent runs on. It is Claude because that is the backend this project's runtime image was built and proved against.

View Source
const DefaultAssistantTimeout = spec.Duration(15 * 60 * 1e9)

DefaultAssistantTimeout bounds one assistant turn when profile.yaml names no timeout.

It exists because NOTHING else bounds one: the assistant has no container and, unlike a playbook, no step cap by default. A wall clock is the right shape for that gap — a step cap fires mid-answer on a turn that is working, which is the bug that took the cap away, while a clock only fires on a turn that is genuinely stuck.

Fifteen minutes is generous by two orders of magnitude: a turn that answers or delegates takes seconds. There is deliberately no way to switch it off, because "off" is the state this constant exists to stop being the default.

View Source
const DockerHostEnv = "DOCKER_HOST"

DockerHostEnv is what a playbook with `docker: true` gets pointed at its own daemon. A playbook without the flag may set it itself — pointing a turn at some other engine is a legitimate thing to want, and nothing is attached for it to collide with.

View Source
const GitCapabilityPrefix = "podium.agent.git_capability."

GitCapabilityPrefix is the reserved prefix of the per-turn secret that carries a turn's authority to mint a GitHub token. The conductor writes one before it creates the task and deletes it when the turn ends, so the name is a turn id and never an operator's choice.

A playbook may not name one: the capability is scoped to the repositories the conductor signed into it, and a playbook that could name another turn's would be a playbook with that turn's repositories. See internal/agent/conductor/gitcred.go.

View Source
const LocalRuntimeImage = "podium-agent-runtime:dev"

LocalRuntimeImage is the tag `make agent-runtime` writes. A node runs tasks on its own Docker engine, so a local tag is visible to a task without any registry — which is exactly what a development stack wants and exactly what a fleet cannot use.

View Source
const MemoryKeyEnv = "PODIUM_MEMORY_API_KEY"

MemoryKeyEnv is where that secret lands in the task container. The brief's memory.api_key_env names it, and the runtime reads it to authenticate its MCP client.

View Source
const MemoryKeySecret = "podium.agent.memory_api_key"

MemoryKeySecret is the other reserved secret the conductor attaches itself: the shared memory's API key. A playbook may not name it and a playbook cannot opt out of memory — only the operator can, by leaving PODIUM_AGENT_MEMORY_URL empty.

View Source
const OpenAIKeyEnv = "OPENAI_API_KEY"

OpenAIKeyEnv is where that secret lands in the task container.

View Source
const OpenAIKeySecret = "podium.agent.openai_api_key"

OpenAIKeySecret is the same reservation for an OpenAI turn: an API key, or the access token of a ChatGPT / Codex subscription sign-in. Unlike xAI the two are not bearers for the same endpoint — a key talks to api.openai.com, a subscription token to ChatGPT's Codex backend — but they still share one secret, because a turn spends exactly one of them and the conductor is what picks the endpoint.

View Source
const OpenAIRefreshSecret = "podium.agent.openai_refresh_token"

OpenAIRefreshSecret is the refresh token of a ChatGPT / Codex sign-in. Same rule as XAIRefreshSecret: reserved, never attached to a turn, never a playbook's to name.

View Source
const RuntimeImageRepo = "ghcr.io/podium-ade/podium-agent-runtime"

RuntimeImageRepo is where the published agent runtime images live. The repository name is fixed; the tag is this build's own version.

View Source
const XAIKeyEnv = "XAI_API_KEY"

XAIKeyEnv is where that secret lands in the task container. It is named for what it holds — an xAI credential — and the runtime is what maps it onto the SDK's ANTHROPIC_AUTH_TOKEN once it knows the turn is a Grok one.

View Source
const XAIKeySecret = "podium.agent.xai_api_key"

XAIKeySecret is the same reservation for a Grok turn: the xAI credential, which is either an API key or the access token of a subscription sign-in. Both are bearer tokens for the same endpoint, so there is one secret and not two.

View Source
const XAIRefreshSecret = "podium.agent.xai_refresh_token"

XAIRefreshSecret is the refresh token of a subscription sign-in. It is stored so a token that expires in an hour does not mean a human signs in every hour, and it never leaves this host: no turn is ever handed it, and no playbook may name it.

Variables

View Source
var Backends = []Backend{{
	ID:           AgentClaude,
	DisplayName:  "Claude",
	Provider:     ProviderAnthropic,
	Note:         "The Claude Agent SDK against Anthropic's API.",
	DefaultModel: "claude-opus-5",
	Models: []Model{
		{
			ID: "claude-opus-5", DisplayName: "Claude Opus 5",
			Note: "The default. Best on long agentic work.", ContextTokens: 1_000_000,
			Efforts: anthropicEfforts,
		},
		{
			ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5",
			Note:          "Cheaper, still strong. A good default for high-volume playbooks.",
			ContextTokens: 1_000_000, Efforts: anthropicEfforts,
		},
		{
			ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5",
			Note:          "Fastest and cheapest. For narrow, well-specified playbooks.",
			ContextTokens: 200_000, Efforts: anthropicEfforts,
		},
		{
			ID: "claude-opus-4-8", DisplayName: "Claude Opus 4.8",
			Note:          "The previous Opus. Pin it when a playbook is tuned to it.",
			ContextTokens: 1_000_000, Efforts: anthropicEfforts,
		},
		{
			ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1",
			Note:          "The most capable model, and the most expensive.",
			ContextTokens: 1_000_000, Efforts: anthropicEfforts,
		},
	},
}, {
	ID:           AgentGrok,
	DisplayName:  "Grok",
	Provider:     ProviderXAI,
	Note:         "The same harness, pointed at xAI's Anthropic-compatible endpoint.",
	DefaultModel: "grok-4.6",
	Models: []Model{
		{
			ID: "grok-4.6", DisplayName: "Grok 4.6",
			Note: "The default. The only Grok that takes xhigh.", ContextTokens: 500_000,
			Efforts: grokEfforts,
		},
		{
			ID: "grok-4.5", DisplayName: "Grok 4.5",
			Note:          "The previous generation. xhigh is treated as high, so it is not offered.",
			ContextTokens: 500_000, Efforts: grokEffortsNoXHigh,
		},
		{
			ID: "grok-4.3", DisplayName: "Grok 4.3",
			Note: "A million tokens of context.", ContextTokens: 1_000_000,
			Efforts: grokEffortsNoXHigh,
		},
		{
			ID: "grok-build-0.1", DisplayName: "Grok Build 0.1",
			Note: "xAI's coding model.", ContextTokens: 256_000,
			Efforts: grokEffortsNoXHigh,
		},
	},
}, {
	ID:           AgentOpenAI,
	DisplayName:  "OpenAI",
	Provider:     ProviderOpenAI,
	Note:         "The same harness, pointed at OpenAI's API — or at ChatGPT's Codex backend after a subscription sign-in.",
	DefaultModel: "gpt-5.4",
	Models: []Model{
		{
			ID: "gpt-5.4", DisplayName: "GPT-5.4",
			Note: "The default. Strong on long agentic work.", ContextTokens: 400_000,
			Efforts: openaiEfforts,
		},
		{
			ID: "gpt-5.5", DisplayName: "GPT-5.5",
			Note:          "The newer flagship.",
			ContextTokens: 400_000, Efforts: openaiEfforts,
		},
		{
			ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 Mini",
			Note:          "Cheaper and faster. A good default for high-volume playbooks.",
			ContextTokens: 400_000, Efforts: openaiEfforts,
		},
		{
			ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex",
			Note:          "OpenAI's coding model. The one a ChatGPT subscription spends.",
			ContextTokens: 400_000, Efforts: openaiEfforts,
		},
	},
}}

Backends is the catalogue, in the order the picker shows it.

It is deliberately a curated shortlist rather than whatever the provider's model list endpoint returns today: this is also the validator, and a picker of forty ids nobody has heard of is not a picker. A model that is not here is still usable — a model id is free text everywhere it is set, and only a *known* id has its effort levels checked.

View Source
var Efforts = anthropicEfforts

Efforts is every level any model here accepts, weakest first.

View Source
var GitEnv = []string{"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"}

GitEnv is the environment git takes an identity from. Every one of these OVERRIDES user.name and user.email, which is why a playbook may not set one alongside a persona: there would be two answers and the quieter one would win.

View Source
var NameRE = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)

NameRE constrains a profile name and a playbook name. A playbook name also has to survive being typed after a slash in Slack.

View Source
var PlaybookPrefixRE = regexp.MustCompile(`^/([a-z][a-z0-9-]{0,31})(\s+|$)`)

PlaybookPrefixRE matches a leading /playbook on a mention. It is anchored at the very start and the name must be followed by whitespace or the end of the text, so "/etc/hosts" is not a playbook selector.

View Source
var Tools = []string{
	"bash", "edit", "glob", "grep", "list", "patch",
	"read", "task", "todoread", "todowrite", "webfetch", "write",
}

Tools is the harness's tool vocabulary, and the set a playbook's allowed_tools is held to. It mirrors KnownTools in agent/runtime/src/opencode.ts.

THIS CHANGED VOCABULARY when the harness did. Playbooks used to name the Claude Agent SDK's tools — Read, Grep, Bash — and the harness that runs them now calls the same things `read`, `grep`, `bash`. The names are validated rather than case-folded on the way through, because a silent remap would quietly work for the tools whose names happen to match and quietly drop the ones that do not. A playbook that names a tool nobody has is refused when it is loaded or saved, which is the cheapest place to find out.

Functions

func AgentNames

func AgentNames() []string

AgentNames is every backend id, for an error message.

func DefaultRuntimeImage

func DefaultRuntimeImage() string

DefaultRuntimeImage is the image a playbook that names none will run in: the runtime published alongside THIS build, so a conductor and its runtime are a matched pair by construction rather than by whoever last edited a tag into a YAML file.

It is why the playbooks Podium ships name no image. `latest` drifts out from under a pinned conductor, and a hard-coded version has to be edited every release — a promise to remember something, and those are the ones that rot.

func KeyEnvFor

func KeyEnvFor(provider string) string

KeyEnvFor is the environment variable name for a provider's credential.

func RemovePlaybook added in v0.2.0

func RemovePlaybook(dir, name string) error

RemovePlaybook deletes playbooks/<name>.yaml. A name that is not there is not an error: deleting twice from a browser is a refresh, not a failure.

func SecretFor

func SecretFor(provider string) string

SecretFor is the reserved Podium secret a provider's credential is stored as, and KeyEnvFor is where that credential lands in a task container.

They are derived from the provider id rather than listed, so adding a provider is one catalogue entry and not a pair of constants somebody has to remember to write. The two spellings they produce for the providers that exist today are asserted against the constants below, which is what stops the derivation drifting from them.

func ValidateOverride

func ValidateOverride(c Choice) error

ValidateOverride holds a per-turn override to the same catalogue a playbook is held to. It is the API boundary's check: a level the chosen model does not accept is refused when it is asked for, not when the turn fails.

The override is checked on its own terms, so a caller that names only an effort is checked against the model it will inherit — which is why Resolve runs first and this takes the resolved triple.

func WritePlaybook added in v0.2.0

func WritePlaybook(dir string, s Playbook) error

WritePlaybook writes playbooks/<name>.yaml under dir. The document is validated first, so a browser cannot land a file the loader would refuse. The name is the file name, not a field: YAML that carries `name:` is not this function's problem (KnownFields would already have refused it on the way in).

Types

type Assistant

type Assistant struct {
	// Skills is the Agent Skills it may use, by name.
	Skills []string
	// MaxTurns caps its steps, and ZERO means no cap: a step cap fires mid-answer on a turn
	// that is working, which is why it is off unless somebody asks for it.
	MaxTurns int
	// Timeout is the wall clock that bounds a turn instead, always positive. It is what
	// stops a stuck turn running for ever on the conductor's own machine.
	Timeout time.Duration
}

Assistant is the bot as a CONVERSATION meets it: the turn the conductor runs itself, in its own process, with no container around it.

It is NOT a playbook and deliberately has no fields in common with one. A playbook is a machine job — an image, a workspace, a Docker daemon, a repository — and the assistant has none of those and can never be given them: it runs on the operator's own host, beside the master key. What it has is this conversation, a short fixed tool list, memory, and the playbooks it may hand work to.

A conversation used to borrow a playbook for its prompt and its model and then have every container-shaped field taken away again by the fence. That left one word meaning two things, and a chat asking a human to choose a container it would never run in. Its prompt is deliberately not here: the assistant IS the profile, so SystemPrompt is already what a turn is told about itself, and a copy in a second field would be one of them going stale.

type Backend

type Backend struct {
	ID          string
	DisplayName string
	Provider    string
	Note        string
	// DefaultModel is what a profile naming this backend and no model runs.
	DefaultModel string
	Models       []Model
}

Backend is one agent backend: a harness, the credential it spends, and its models.

func FindBackend

func FindBackend(id string) (Backend, bool)

FindBackend is the backend with this id, or false. An empty id is DefaultAgent.

func (Backend) FindModel

func (b Backend) FindModel(id string) (Model, bool)

FindModel is a known model of a backend, or false. An unknown id is not an error anywhere: it is how a model released after this binary was built gets used.

type Choice

type Choice struct {
	Agent  string
	Model  string
	Effort string
}

Choice is what a turn actually runs on. Every field is resolved: Agent and Model are never empty, and Effort is empty only when nothing anywhere named one, which means the model's own default.

type GitPersona

type GitPersona struct {
	Name  string `yaml:"name" json:"name"`
	Email string `yaml:"email" json:"email"`
}

GitPersona is who a turn's commits are BY: the user.name and user.email the runtime writes into every clone it makes.

It has to be an identity GitHub can resolve to an ACCOUNT, and that is a stronger requirement than "a well-formed address". GitHub links a commit to an account by this email; an @users.noreply.github.com address belonging to no account — which is what the runtime's own fallback is — leaves every commit attributed to nobody, and a deployment gate that checks the author's access refuses the pull request. The account whose token the playbook pushes with is the right answer, as <id>+<login>@users.noreply.github.com.

Empty is allowed and means the runtime's fallback, because a profile written before this field existed must still load.

func (GitPersona) Set

func (g GitPersona) Set() bool

Set reports whether this persona says anything at all. validate has already refused a half-written one, so a persona that is Set always has both halves.

type Live

type Live struct {
	// contains filtered or unexported fields
}

Live is the profile a running conductor reads. The file half is loaded at start and swapped only when an operator asks for it, because a directory somebody is halfway through saving is not a profile and a timer cannot tell the difference. Overrides from the Assistant screen are applied on top and swapped whenever they change.

Every reader takes Current() once and works from the value it got. A turn therefore runs the playbook it started with even if that playbook is edited while it is in flight: Playbook is a value, and swapping the profile behind it changes nothing about the copy already taken.

func NewLive

func NewLive(files *Profile) *Live

NewLive holds files as both the file half and, until the first Set, the current profile. A conductor that cannot reach its database still runs the directory it was given.

func (*Live) Current

func (l *Live) Current() *Profile

Current is the profile in force. Nil only when there is no profile at all.

func (*Live) Files

func (l *Live) Files() *Profile

Files is profile.yaml and playbooks/*.yaml as they were last read off disk, with nothing merged in. It is what the UI shows beside an override.

func (*Live) Set

func (l *Live) Set(p *Profile)

Set swaps the profile every later reader will see.

func (*Live) SetFiles

func (l *Live) SetFiles(p *Profile)

SetFiles replaces the file half with what a re-read of the profile directory found.

It is separate from Set because the two halves are read apart — Files() is the file's value the UI shows beside an override, Current() is what a turn runs — and a caller that re-reads the directory has to write both. Write this one first: a profile built from files nobody can see would leave the UI explaining an override against the wrong file.

type Model

type Model struct {
	ID            string
	DisplayName   string
	Note          string
	ContextTokens int
	// Efforts are the levels this model accepts, weakest first. Empty means the model takes
	// no effort setting at all, and naming one for it is refused.
	Efforts []string
}

Model is one model a backend can be pointed at.

type Override

type Override struct {
	Agent  string
	Model  string
	Effort string
}

Override is a per-turn choice of backend, model and effort. Every field is optional and an empty one means "whatever the level below says" — the playbook, then the profile.

It exists so that "which job" and "what runs it" are two decisions rather than one. A playbook's model is a default; without an override the only way to run one playbook on another model is a second playbook that differs by a single field.

func (Override) Empty

func (o Override) Empty() bool

Empty reports whether this override asks for nothing.

type Overrides

type Overrides struct {
	DisplayName     string    `json:"display_name,omitempty"`
	Model           string    `json:"model,omitempty"`
	Agent           string    `json:"agent,omitempty"`
	Effort          string    `json:"effort,omitempty"`
	DefaultPlaybook string    `json:"default_playbook,omitempty"`
	UpdatedBy       string    `json:"updated_by,omitempty"`
	UpdatedAt       time.Time `json:"updated_at,omitzero"`
}

Overrides is the part of profile.yaml a browser may change. Every field is an override: empty means "whatever the file says", which is also how an override is cleared. The profile's name and system prompt are deliberately absent — the name labels every session row already written, and the prompt is the operator's own file.

func (Overrides) Fields

func (o Overrides) Fields() []string

Fields names the profile.yaml keys this override is supplying, in file order. The UI shows the file's value beside each one so an operator can see what is being overridden.

func (Overrides) Trim

func (o Overrides) Trim() Overrides

Trim is the override as it is stored: every field whitespace-trimmed, so a field a human cleared to spaces is an empty override rather than an invalid value.

type Playbook

type Playbook struct {
	Image        string        `yaml:"image" json:"image"`
	SystemPrompt string        `yaml:"system_prompt" json:"system_prompt"`
	AllowedTools []string      `yaml:"allowed_tools" json:"allowed_tools"`
	MaxTurns     int           `yaml:"max_turns" json:"max_turns"`
	Timeout      spec.Duration `yaml:"timeout" json:"timeout"`
	Model        string        `yaml:"model" json:"model,omitempty"`
	Agent        string        `yaml:"agent" json:"agent,omitempty"`
	Effort       string        `yaml:"effort" json:"effort,omitempty"`
	Labels       []string      `yaml:"labels" json:"labels,omitempty"`
	// Priority is where a turn of this playbook goes in Podium's queue: the scheduler
	// claims higher first and breaks ties by age. Zero is the default and negative is
	// allowed, so a playbook that grinds for two hours can be told to wait behind
	// everything somebody is watching.
	//
	// It is a sort key and not a budget: it changes what runs next when the fleet is full,
	// and nothing at all about what a turn is given or how long it may take.
	Priority  int              `yaml:"priority" json:"priority,omitempty"`
	Resources spec.Resources   `yaml:"resources" json:"resources,omitempty"`
	Secrets   []spec.SecretRef `yaml:"secrets" json:"secrets,omitempty"`
	Repos     []Repo           `yaml:"repos" json:"repos,omitempty"`
	// Git is who this playbook's turns commit as: it overrides the profile's, and unset
	// inherits it. Unset in both leaves the runtime's fallback, which belongs to no GitHub
	// account — see GitPersona for why that is a deployment failure rather than a cosmetic one.
	Git           GitPersona        `yaml:"git" json:"git,omitzero"`
	SlackChannels []string          `yaml:"slack_channels" json:"slack_channels,omitempty"`
	Env           map[string]string `yaml:"env" json:"env,omitempty"`
	// Skills is the Agent Skills a turn of this playbook may use, by name, out of
	// PODIUM_AGENT_SKILLS_DIR on the conductor's host. Nothing is implicit: a playbook that
	// names none gets none, and the harness's own permission map denies every skill it has
	// not been told about — including the ones built into the harness.
	//
	// An Agent Skill is executable content somebody else wrote, and it runs in the turn's
	// container with that turn's credentials. This list is the whole of what decides which
	// ones do. See docs/security.md.
	Skills []string `yaml:"skills" json:"skills,omitempty"`
	// MCPServers is the MCP servers a turn of this playbook may use, by name, out of the
	// registry an operator manages in the web UI. Nothing is implicit here either: a
	// playbook that names none gets none, and the runtime writes the harness exactly the
	// entries the brief carried.
	//
	// A server is somebody else's API with a credential attached, and the tools it exposes
	// run with whatever that credential can do. This list is the whole of what decides
	// which turns get which of them. See docs/security.md.
	MCPServers []string `yaml:"mcp_servers" json:"mcp_servers,omitempty"`
	// Docker gives the turn a real Docker daemon beside it: the conductor attaches a
	// privileged `dind` sidecar and points DOCKER_HOST at it. A playbook needs this to run a
	// dev stack, `docker compose`, or testcontainers.
	//
	// It only works on a node started with --allow-privileged-sidecars, and Podium places
	// on labels alone, so a playbook that sets this must also carry a label its operator put
	// on those nodes. Getting that wrong fails the turn with a message naming the flag
	// rather than hanging.
	Docker bool `yaml:"docker" json:"docker,omitempty"`

	// Browser gives the turn a headless Chrome of its own and the tools to drive it: the
	// conductor adds a browser sidecar and the runtime points an MCP server at it, so the
	// agent navigates, clicks and screenshots rather than shelling out to curl.
	//
	// The browser is a SIDECAR and not something in the image, which is what keeps it
	// isolated: its own container, its own profile, its own network namespace, thrown away
	// with the task. It needs no privilege — unlike `docker`, this asks nothing of the node
	// beyond an ordinary container, so it carries no label requirement.
	Browser bool `yaml:"browser" json:"browser,omitempty"`

	// Linear marks the one playbook Linear tickets run. Tickets are not chat, so there is no
	// /playbook prefix to route them and no channel to match: the flag is the routing rule.
	// At most one playbook may set it; zero means this bot does not take tickets, which is
	// only a misconfiguration when a Linear API key is also set — and the conductor says
	// so at start-up, where the key is known.
	Linear bool `yaml:"linear" json:"linear,omitempty"`

	// Interactive lets a turn ask a human a question and wait for the answer in the same
	// container, instead of ending the turn. Off by default: a waiting container still
	// holds a node slot (and a dind sidecar, if the playbook asked for one).
	Interactive bool `yaml:"interactive" json:"interactive,omitempty"`

	// Name is the file name without the extension.
	Name string `yaml:"-" json:"-"`
}

Playbook is one job the bot can do: which image, which prompt, which tools, which secrets.

type Profile

type Profile struct {
	Name         string `yaml:"name"`
	DisplayName  string `yaml:"display_name"`
	SystemPrompt string `yaml:"system_prompt"`
	Model        string `yaml:"model"`
	// Agent is the backend every playbook runs on unless it names its own. Empty is
	// DefaultAgent, so a profile.yaml written before Grok existed still loads.
	Agent string `yaml:"agent"`
	// Effort is the reasoning effort every playbook runs at unless it names its own. Empty
	// means the model's own default, which is what the provider picks.
	Effort string `yaml:"effort"`
	// DefaultPlaybook is unused. It is still decoded so a profile.yaml that names one
	// continues to load; Select does not fall back to it.
	DefaultPlaybook string `yaml:"default_playbook"`
	// Git is who every playbook's turns commit as unless the playbook names its own. It is
	// the DEFAULT and not the rule: the identity has to match the account behind the token
	// that pushes, and the GitHub token is a per-playbook secret, so a second playbook
	// pushing with a second token needs a persona of its own.
	Git GitPersona `yaml:"git"`
	// Skills is the Agent Skills the ASSISTANT may use — the turn the conductor answers a
	// conversation with, on this host. A playbook names its own; this is the other end of
	// that list and not a default for it, because the two turns are nothing alike: one has
	// a container and a workspace, and this one has a conversation.
	Skills []string `yaml:"skills"`
	// Timeout bounds one assistant turn on the wall clock. Zero is DefaultAssistantTimeout;
	// unlike MaxTurns there is no "off", because a turn with neither bound has no automatic
	// stop at all.
	Timeout spec.Duration `yaml:"timeout"`
	// MaxTurns caps one assistant turn's steps. UNSET MEANS NO CAP, which is the opposite of
	// a playbook's max_turns and deliberately so: the assistant answers a conversation and
	// delegates, so what is worth bounding is the container it starts rather than the relay
	// that started it, and a cap that fires mid-answer says "I ran out of turns" about a turn
	// that had not failed. An operator who wants a ceiling sets one.
	MaxTurns int `yaml:"max_turns"`

	// Playbooks is every playbooks/*.yaml, keyed by file name without the extension.
	Playbooks map[string]Playbook `yaml:"-"`
	// Dir is where the profile was loaded from.
	Dir string `yaml:"-"`
}

Profile is the bot: one identity, one agent backend, one model, a set of playbooks.

func Apply

func Apply(files *Profile, ov Overrides) (*Profile, error)

Apply is the profile a turn actually runs from: the profile directory with the Assistant- screen overrides on top. Playbooks come only from files.

The result is validated exactly as Load validates the directory. A default_playbook that names nothing loaded is not a load error: a fresh install may have an empty playbooks/ directory, and Select refuses the mention instead of refusing to boot.

func Load

func Load(dir string) (*Profile, error)

Load reads profile.yaml and every playbooks/*.yaml under dir. Every decode uses KnownFields(true), as pkg/spec.ParseTaskSpec does: a misspelt key is an error naming the file, not a field that silently does nothing.

func (*Profile) AgentFor

func (p *Profile) AgentFor(s Playbook) string

AgentFor is the backend a playbook runs on: its own, then the profile's, then DefaultAgent. It never returns "": a turn always runs on something, and the brief says which.

func (*Profile) Assistant

func (p *Profile) Assistant() Assistant

Assistant is what answers a conversation. Every field comes from profile.yaml itself: there is no playbook in this path and nothing to select.

func (*Profile) EffortFor

func (p *Profile) EffortFor(s Playbook) string

EffortFor is the reasoning effort a playbook runs at, or "" for the model's own default.

Inheriting the profile's level is safe because validateTriple has already refused the combination that would make it wrong — a playbook that switches backend and inherits a level its new model does not accept fails to load rather than running at a level nobody chose.

func (*Profile) GitFor

func (p *Profile) GitFor(s Playbook) GitPersona

GitFor is who a turn of a playbook commits as: the playbook's own persona, then the profile's. An empty one is a real answer — it leaves the runtime's fallback in place.

func (*Profile) LinearPlaybook

func (p *Profile) LinearPlaybook() string

LinearPlaybook is the name of the playbook Linear tickets run, or "" when no playbook claims them. validate has already refused more than one.

func (*Profile) ModelFor

func (p *Profile) ModelFor(s Playbook) string

ModelFor is the model a playbook runs on: its own if it named one, the profile's otherwise.

func (*Profile) PlaybookNames

func (p *Profile) PlaybookNames() []string

PlaybookNames is every loaded playbook, sorted.

func (*Profile) Resolve

func (p *Profile) Resolve(s Playbook, o Override) Choice

Resolve is what a TASK actually runs on: the override, then the playbook, then the profile, then the built-in default. It is the ONE place that ordering lives, so the conductor, the brief and the credential the turn is handed can never disagree about it.

func (*Profile) ResolveAssistant

func (p *Profile) ResolveAssistant(o Override) Choice

ResolveAssistant is the same for a turn the conductor answers a conversation with. There is no playbook in that path, so the starting point is the profile's own triple — which is what the assistant is — and the override is the composer's model picker.

func (*Profile) Select

func (p *Profile) Select(r Routing) Selection

Select applies the routing rules in order: a playbook the source knows, then a leading /playbook the human typed, then the channel's claim. There is no default playbook: an operator registers the ones the assistant may delegate to, and a ticket or /name that matches none of them is refused. An unknown /name is deliberately not an error — somebody typing /shrug must not break the bot — it is left in the text and falls through.

type Repo

type Repo struct {
	Name          string `yaml:"name" json:"name"`
	URL           string `yaml:"url" json:"url"`
	DefaultBranch string `yaml:"default_branch" json:"default_branch"`
}

Repo is a repository a playbook's turns get cloned into /workspace.

type Routing

type Routing struct {
	// Playbook is a playbook the source KNOWS is right, and which no routing rule may
	// second-guess: Linear's linear: true playbook. Empty means the rules decide. Slack
	// always leaves it empty.
	Playbook string
	// Channel is the routing key matched against a playbook's slack_channels.
	Channel string
	// Text is what the human said, a /playbook prefix included.
	Text string
}

Routing is what Select decides from.

It only ever describes a TASK: a Slack thread or a Linear ticket, which are one piece of work and run one playbook in a container. A conversation does not appear here at all — it is answered by the assistant, and the playbooks are what that turn delegates to rather than something a routing rule picks for it.

type Selection

type Selection struct {
	// Playbook is the playbook that will run the turn.
	Playbook Playbook
	// Instruction is the triggering text with a /playbook prefix stripped.
	Instruction string
	// Explicit is true when the caller named the playbook — a /playbook prefix, or a source that
	// chose one itself. An explicit playbook that disagrees with an existing session's playbook is
	// refused rather than honoured: one session, one playbook. A default is never explicit.
	Explicit bool
}

Selection is what Select decided.

Jump to

Keyboard shortcuts

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