api

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package api is the conductor's own HTTP surface: the AgentService handlers, the bearer that guards them, and the health and metrics endpoints. There is no other way in — the listener is loopback by default and podium-server is what a browser reaches it through.

Index

Constants

View Source
const (
	DevInboundPath  = "/dev/inbound"
	DevOutboundPath = "/dev/outbound"
)

Dev source routes. They exist only when PODIUM_AGENT_DEV_SOURCE is true and they sit behind the same bearer as the AgentService.

View Source
const (
	OAuthPending  = "pending"
	OAuthSlowDown = "slow_down"
	OAuthDone     = "done"
	OAuthDenied   = "denied"
	OAuthExpired  = "expired"
)

The states PollProviderOAuth reports. They are the whole client contract: everything the provider can answer maps onto one of these five.

View Source
const (
	ProviderAnthropic = profiles.ProviderAnthropic
	ProviderXAI       = profiles.ProviderXAI
)

The providers this control plane knows. A provider is a set of credentials and an endpoint; profiles.Backends is what says which agent backend spends which of them.

View Source
const (
	AuthAPIKey = "api_key"
	AuthOAuth  = "oauth"
)

How a stored credential was obtained. The distinction is not cosmetic: an API key does not expire and an access token does, so only one of the two is refreshed and only one of the two has a hint worth showing.

View Source
const DefaultUsageDays = 30

DefaultUsageDays is the range GetUsage reads when the request names neither end.

View Source
const LoginHeader = "X-Podium-Login"

LoginHeader is what podium-server's proxy sets to the calling operator's login. Any client-supplied copy is stripped there, so what arrives here is the server's word.

View Source
const TurnTokenHeader = "X-Podium-Turn"

TurnTokenHeader carries a turn's token. A header and not a request field: it is a credential, and a credential in a message body is a credential in every log that ever prints a request.

Variables

This section is empty.

Functions

func Login

func Login(ctx context.Context) string

Login is the operator the server says is calling, or "unknown" when a handler was reached without the middleware.

func ReloadProfile

func ReloadProfile(ctx context.Context, st *store.Store, live *profiles.Live) (*profiles.Profile, error)

ReloadProfile rebuilds the profile a turn runs from — the profile directory with the stored overrides on top — and swaps it into live.

This is the whole of "a change reaches a running conductor without a restart": every reader takes live.Current() per use, so the swap is the only thing that has to happen. It is a plain function because the process calls it at boot, before the AgentService exists, as well as on every override write and on a timer.

func ReloadProfileDir

func ReloadProfileDir(
	ctx context.Context, st *store.Store, live *profiles.Live, dir string,
) (*profiles.Profile, error)

ReloadProfileDir is the same rebuild after re-reading the profile directory off disk, which is the half a restart used to be the only way to change: profile.yaml, playbooks/*.yaml and every prompt a `file:` points at.

**Nothing is swapped until all of it succeeds.** A directory in the middle of being edited is a load error, and a load error has to leave the conductor running the profile it already has rather than half of a new one. That is also why this is only ever called for an operator who asked: a timer re-reading a file somebody is saving would be a way to break a working bot by touching a keyboard.

func RequireBearer

func RequireBearer(token string, next http.Handler) http.Handler

RequireBearer refuses everything that does not present the conductor's token, and puts the proxied login in the request context. The comparison is constant time: the token is the only thing standing between a local process and every session the bot has had.

Types

type AgentService

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

AgentService implements podium.agent.v1.AgentService.

func NewAgentService

func NewAgentService(opts AgentServiceOptions) *AgentService

NewAgentService returns the handlers.

func (*AgentService) AttachChatPullRequest

AttachChatPullRequest links a pull request to one of the caller's chats by hand.

func (*AgentService) ClearMcpServerToken

ClearMcpServerToken removes the credential and leaves the registration. The goal state is "no token", so a NotFound from the control plane is success: calling it twice is not an error.

func (*AgentService) ClearProviderKey

ClearProviderKey removes the credential and the metadata. The goal state is "no credential", so a NotFound from the control plane is success: calling it twice is not an error.

func (*AgentService) CompleteMcpOAuth

CompleteMcpOAuth trades the code for a token and stores it as the server's credential.

From here on there is no difference between a sign-in and a pasted token: the access token goes into the same Podium secret, so the brief, the task spec and the runtime cannot tell them apart. What the sign-in leaves extra is the bag that lets it be refreshed.

func (*AgentService) CreateChat

CreateChat opens a conversation owned by the caller.

func (*AgentService) CreateMcpServer

CreateMcpServer registers a new server, and stores its token when one came with it.

The order is the registration first and the token second, which is the opposite of SetProviderKey's. A provider key is validated with the provider before anything is written; an MCP server cannot be asked whether it is there without also being handed the credential, so there is nothing to validate against and no reason to write the secret before the row that owns it.

func (*AgentService) CreatePlaybook

CreatePlaybook exists so the wire stays the same. Playbooks are files; it refuses.

func (*AgentService) DeleteChat

DeleteChat removes one of the caller's chats and every message in it.

A task still answering the chat is cancelled first. The transcript is about to disappear, and a container nobody is listening to would otherwise keep the node slot and can still finish work the person who asked for it will never see. Cancel does not wait: the chat is gone immediately, the node has up to 30s. Sessions and turns stay.

func (*AgentService) DeleteMcpServer

DeleteMcpServer removes a registration and the token with it.

A server a playbook still names is deletable, exactly as a skill is: profiles deliberately does not check that a named server exists — a playbook file has to load on a machine with no database — so refusing here would be the only place in Podium where the two disagreed. The playbook's turns then fail naming the server, and ListMcpServers reports which playbooks name each one so a human can see that before pressing the button.

The secret goes first. A row that is gone with a credential left behind it is a credential nothing in the UI can see, name or remove.

func (*AgentService) DeleteMemory

DeleteMemory takes one memory out of every future recall.

The memory engine has no single-memory delete, so this is its curation tombstone: the memory is excluded from recall, from consolidation and from the graph, the observations derived from it are pruned, and the row is kept in an archive. What the operator asked for holds — no future turn sees it, and neither does this UI — but the record of it having existed is not destroyed.

func (*AgentService) DeletePlaybook

DeletePlaybook refuses: playbooks are files.

func (*AgentService) DeleteSkill

DeleteSkill refuses: skills are directories.

func (*AgentService) DetachChatPullRequest

DetachChatPullRequest takes one link off one of the caller's chats.

func (*AgentService) GetProfile

GetProfile reports the profile in force and every playbook in full.

A playbook's secrets are reported by NAME, which is all a playbook file holds either. No value reaches this response: Podium has no endpoint that reads a secret's value back, and a playbook naming one is not a privilege — a task spec names secrets exactly the same way.

func (*AgentService) GetSession

GetSession reads one session by id.

func (*AgentService) GetSettings

GetSettings reports what the conductor is configured with.

key_set means "an agent turn will find a credential", so it comes from the **control plane**, not from the metadata row: the secret is the control plane's and `podium secret rm` can take it away without the conductor hearing. The row only supplies the hint, the login and the time, and only when it still describes the version that is actually stored. Nothing here reads a value — there is no endpoint that could.

func (*AgentService) GetUsage

GetUsage reports what the conductor spent over a range: one row per day for a calendar, and the per-task cost of each turn inside it.

func (*AgentService) HostCredential

func (s *AgentService) HostCredential(ctx context.Context, provider string) (string, error)

HostCredential is the bearer a host turn spends for one provider. It is the same credential the provider's Podium secret holds, which is what makes a host turn and a container turn spend the same thing.

The two ways of having nothing are told apart, because they need different actions from an operator. No row at all is somebody who has not set a credential. A row with no readable copy is somebody who HAS — every credential stored before host turns existed is like that, since the secret store it went into has no read endpoint — and the fix is to save it again rather than to go looking for a screen that already says Connected.

func (*AgentService) ListAgents

ListAgents is the picker's catalogue: the backends this conductor can run a turn on, the models each offers, and whether a credential for it is actually stored.

func (*AgentService) ListChats

ListChats returns the caller's own chats, newest first.

func (*AgentService) ListMcpServers

ListMcpServers reports every registered server.

func (*AgentService) ListMemories

ListMemories pages through the shared memory, newest first.

func (*AgentService) ListPlaybooks

ListPlaybooks reports who answers a conversation and what that turn may delegate to.

It is the chat's surface and stays minimal: per playbook a name, an image and the first line of a prompt the operator wrote. The whole definition — tools, secrets, resources — is what GetProfile is for.

func (*AgentService) ListSessions

ListSessions returns conversations newest first.

func (*AgentService) ListSkills

ListSkills reports every skill this conductor can hand a turn: the directories under PODIUM_AGENT_SKILLS_DIR. A directory that is there and will not load is reported with the reason attached rather than hidden.

func (*AgentService) ListTurns

ListTurns returns one session's turns, newest first.

func (*AgentService) PollProviderOAuth

PollProviderOAuth asks the provider whether the human has approved yet, and stores the credential on the one call that comes back authorised.

The token is validated against the provider's own API before it is stored, exactly as a pasted key is. That is not ceremony: xAI's OAuth surface has its own allow-list, so a sign-in can succeed and still produce a token that cannot call the API — and finding that out now, with the provider's own sentence, beats finding it out on the first turn.

func (*AgentService) Reconcile

func (s *AgentService) Reconcile(ctx context.Context) error

Reconcile is the timer's rebuild: the stored overrides, re-read on an interval so a row changed by another conductor or by psql lands here too.

It takes the write lock, and that is the whole reason it exists as a method of its own. A rebuild is a read of the file half, a read of the database and then a swap; a timer doing that unlocked could read the files, have ReloadProfileDir replace them underneath it, and then swap in a profile built from a directory nobody is running any more — undoing an operator's re-read seconds after they asked for it.

func (*AgentService) RefreshTokens

func (s *AgentService) RefreshTokens(ctx context.Context)

RefreshTokens keeps stored OAuth credentials alive until the process stops — the subscription sign-in's, and every signed-in MCP server's.

It runs as its own goroutine because nothing else is watching: a token issued for an hour would otherwise stop working an hour after a sign-in, and the first anybody would hear of it is a turn failing. A refresh that fails is logged and retried on the next tick — the stored token is left alone, because a token that still has thirty minutes on it is more use than no token at all.

func (*AgentService) ReloadProfileDir

ReloadProfileDir re-reads the profile directory and swaps what it finds in.

The failure is an InvalidArgument and not an Internal on purpose: what fails here is almost always the YAML the operator has just written, and the message profiles.Load returns names the file and the line. Nothing is swapped when it does, so the stale reason is not touched either — the conductor is still running the profile it was, and the error in front of the operator is the whole report.

func (*AgentService) RenameChat

RenameChat changes the title of one of the caller's chats.

func (*AgentService) SearchMemories

SearchMemories is the semantic search behind the Memory tab's search box.

func (*AgentService) SendChatMessage

SendChatMessage stores one human message and starts a turn on it.

func (*AgentService) SetMcpServerToken

SetMcpServerToken stores the credential a server authenticates with.

It is not validated, because there is no way to validate it. An MCP server has no unauthenticated "are you there" call, so the only test of a token is a turn using it — which is why the failure an operator sees for a bad token is a turn's, and why the hint exists at all.

func (*AgentService) SetProviderKey

SetProviderKey validates a key with the provider and, only then, stores it.

The order is the contract: validate, write the secret, write the metadata. An operator who reads "saved" has to be able to trust that agents will run, so an unvalidated key is never written, and a metadata row never claims a key the secret store does not hold.

func (*AgentService) SetSkillEnabled

SetSkillEnabled refuses: skills are directories.

func (*AgentService) StartMcpOAuth

StartMcpOAuth discovers a server's OAuth, registers this conductor as a client of it, and answers with the URL to send the operator's browser to.

Everything expensive happens here rather than on the callback, so that a server which advertises no OAuth, or refuses a registration, is a refusal the operator gets while looking at the button they pressed — not a dead end after a round trip through a consent screen.

func (*AgentService) StartProviderOAuth

StartProviderOAuth asks the provider for a code a human can approve, and remembers the device code that goes with it. It stores no credential: only a poll that comes back authorised does that.

func (*AgentService) StreamChat

StreamChat replays a chat and then follows it live.

The order is: subscribe, send the turn state, replay, then forward. Subscribing first is what closes the gap — a message stored between the replay and the subscription would otherwise be seen by nobody — and the cost is that a frame may arrive twice, which is harmless because a client keys on seq.

The status frame goes FIRST and unconditionally, for two reasons. It is what tells a browser joining mid-turn that the composer is disabled without waiting for the next progress line. And it is what puts the response headers on the wire immediately: a Connect server stream writes no headers until its first message, and podium-server's proxy gives the conductor 30 seconds to produce them — so a subscriber on a quiet chat would otherwise be disconnected every 30 seconds and reconnect for ever.

It never returns on its own. The client cancels, and the request context is what unwinds the subscription.

func (*AgentService) UpdateMcpServer

UpdateMcpServer replaces a registration. The token is untouched: it belongs to the name, and the name is what cannot be edited here.

func (*AgentService) UpdatePlaybook

UpdatePlaybook refuses: playbooks are files.

func (*AgentService) UpdateProfile

UpdateProfile stores the overrides and swaps the rebuilt profile in. Every field is an override of profile.yaml and an empty one clears it, so "use the file's value" needs no second RPC.

func (*AgentService) UploadSkill

UploadSkill exists so the wire stays the same. Skills are directories; it refuses.

type AgentServiceOptions

type AgentServiceOptions struct {
	Store   *store.Store
	Secrets SecretStore
	// Model is the model reported by GetSettings when no profile is loaded. The profile's
	// own model wins when there is one, because it is what a turn will actually run.
	Model string
	// AnthropicBaseURL is where SetProviderKey validates an Anthropic key.
	AnthropicBaseURL string
	// XAIBaseURL is where it validates an xAI credential, key or token.
	XAIBaseURL string
	// XAIOAuthIssuer, XAIOAuthClientID and XAIOAuthScopes configure the subscription
	// sign-in. An empty client id means this control plane offers API keys only, which is
	// a supported configuration and not an error.
	XAIOAuthIssuer   string
	XAIOAuthClientID string
	XAIOAuthScopes   string
	// HTTPClient validates the key. Nil means a client with a timeout of its own.
	HTTPClient *http.Client
	// Memory is the shared-memory client. Nil is a supported configuration: the three
	// memory RPCs then answer FailedPrecondition and the UI says memory is not configured.
	Memory memory.Client
	// SkillsDir is PODIUM_AGENT_SKILLS_DIR: the Agent Skills on this host. Empty means this
	// conductor delivers no skills, and a playbook that names one fails that turn.
	SkillsDir string
	// Profiles is the profile in force, swapped when the directory is re-read or an override
	// changes. Nil makes the playbook and profile RPCs answer FailedPrecondition.
	Profiles *profiles.Live
	// ProfileDir is PODIUM_AGENT_PROFILE_DIR, the directory Profiles' file half was read
	// from. It is what ReloadProfileDir re-reads. Empty means this conductor has no
	// directory to go back to and that RPC answers FailedPrecondition.
	ProfileDir string
	// Chat is the web chat's write path and live fan-out. Nil makes the chat RPCs answer
	// FailedPrecondition.
	Chat ChatSource
	// Tasks stops a running Podium task. DeleteChat uses it so a conversation that still
	// has a turn in flight does not leave a container running after it is gone. Nil is a
	// supported configuration: tests that never start a turn omit it.
	Tasks TaskCanceller
	// Turns stops the work a conversation owns that no task id can reach: the host turn
	// answering it, which runs in this process, and the tasks that turn delegated. Nil is
	// a conductor with no host turns, and DeleteChat then has nothing extra to stop.
	Turns  TurnStopper
	Logger *slog.Logger
}

AgentServiceOptions is what the handlers need. Everything but Store and Secrets is optional; without Secrets the settings RPCs answer FailedPrecondition rather than panic.

type ChatSource

type ChatSource interface {
	Send(ctx context.Context, req chat.SendRequest) (store.ChatMessage, error)
	Subscribe(ctx context.Context, chatID string) *chat.Subscriber
	Running(ctx context.Context, chatID string) (bool, error)
	Awaiting(chatID string) bool
	AttachPullRequest(ctx context.Context, chatID, login, url string) ([]store.ChatPullRequest, error)
	DetachPullRequest(ctx context.Context, chatID, login, url string) ([]store.ChatPullRequest, error)
}

ChatSource is the part of the chat source the handlers use: the write paths, and the live fan-out. Reads go to the store directly.

The pull-request writes are here rather than on the store because the point of them is the frame: every browser watching the conversation has to see the list change, not only the one that pressed the button.

type Delegator

type Delegator interface {
	Delegate(ctx context.Context, token, playbook, instruction string) (store.Delegation, error)
	GetDelegation(ctx context.Context, token, id string) (store.Delegation, string, error)
	Delegations(ctx context.Context, token string) ([]store.Delegation, error)
	CancelDelegation(ctx context.Context, token, id, reason string) (store.Delegation, error)
	InjectDelegation(ctx context.Context, token, id, text string) (store.Delegation, error)
}

Delegator is the conductor, as this service needs it. An interface rather than the concrete type so the handler can be tested without a turn loop, a database or a control plane behind it.

type DevSource

type DevSource struct {
	*fakesource.Source
}

DevSource is the in-memory source with two HTTP routes bolted on. It is TEST ONLY: it lets a test inject an inbound message as if a human had sent it and read back every single thing the conductor said, in order, without a Slack workspace.

func NewDevSource

func NewDevSource() *DevSource

NewDevSource returns a started-and-ready dev source.

func (*DevSource) Handler

func (d *DevSource) Handler() http.Handler

Handler mounts the two dev routes.

type GitCredentialService

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

GitCredentialService implements podium.agent.v1.GitCredentialService.

func NewGitCredentialService

func NewGitCredentialService(minter GitMinter, logger *slog.Logger) *GitCredentialService

NewGitCredentialService returns the handler. A nil minter is a conductor with no GitHub App: every call then answers FailedPrecondition, which is the truth rather than a crash.

func (*GitCredentialService) MintToken

MintToken issues a GitHub installation token for the calling turn.

type GitMinter

type GitMinter interface {
	MintGitToken(ctx context.Context, capability string) (conductor.GitCredential, error)
}

GitMinter is the conductor, as this service needs it. An interface so the handler can be tested without a GitHub App, a database or a turn loop behind it.

type SecretStore

type SecretStore interface {
	SetSecret(ctx context.Context, name string, value []byte) (version int32, err error)
	DeleteSecret(ctx context.Context, name string) error
	SecretVersion(ctx context.Context, name string) (int32, error)
}

SecretStore is the part of the Podium API the settings handlers need. It is an interface rather than the client itself so a test needs no control plane, and so this package keeps depending on nothing but a name, some bytes and a version.

SecretVersion is what keeps the conductor honest. The secret belongs to the control plane and an operator can set or remove it with `podium secret set`/`rm` without the conductor hearing about it, so the metadata row here is never taken as proof that a key exists — the control plane is asked. ListSecrets carries metadata only; no value is ever read back.

type TaskCanceller

type TaskCanceller interface {
	CancelTask(ctx context.Context, taskID, reason string) error
}

TaskCanceller stops a Podium task. It is the same CancelTask `podium task cancel` calls: the node gets SIGTERM and up to 30s, and nothing here waits.

type TurnService

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

TurnService implements podium.agent.v1.TurnService.

func NewTurnService

func NewTurnService(delegator Delegator, logger *slog.Logger) *TurnService

NewTurnService returns the handler. A nil delegator is a conductor that runs no host turns: every call then answers FailedPrecondition, which is the truth rather than a crash.

func (*TurnService) CancelDelegation

CancelDelegation stops one.

func (*TurnService) Delegate

Delegate starts a task for the calling turn's conversation.

func (*TurnService) GetDelegation

GetDelegation is where a delegated task has got to. It is what a turn polls, so it is deliberately cheap: one row and the last thing the task said.

func (*TurnService) InjectDelegation

InjectDelegation delivers one human message into a running delegated task.

func (*TurnService) ListDelegations

ListDelegations is everything the calling turn has delegated.

type TurnStopper

type TurnStopper interface {
	// CancelHostTurn stops the host turn answering ref, reporting whether there was one.
	CancelHostTurn(ref string) bool
	// CancelDelegationsForRef stops every delegated task of one conversation.
	CancelDelegationsForRef(ctx context.Context, ref, reason string) (int, error)
}

TurnStopper is the conductor, as deleting a chat needs it. Neither of these can be reached with a task id: a host turn is a child process of this conductor and has no task, and the tasks it delegated are owned by the CONVERSATION rather than by the turn that asked for them — which is precisely why deleting the conversation has to end them.

Jump to

Keyboard shortcuts

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