Outpost Service
Outpost is a standalone HTTP service that runs on a machine alongside Polytoken. It provides an authenticated network endpoint for remote access to that machine’s Polytoken sessions, without requiring SSH tunnels or manual port forwarding. Outpost manages its own authentication, TLS, and connection filtering independently from the local daemon.
When to use Outpost
Section titled “When to use Outpost”Outpost is for scenarios where a process on another machine needs to reach a Polytoken daemon. Examples include CI pipelines driving sessions on a build server, or a remote workstation connecting to a headless machine. When Polytoken runs locally and no external process needs to connect, you do not need Outpost.
Configuration
Section titled “Configuration”Outpost reads its configuration from outpost-config.yaml. The file lives at
$XDG_CONFIG_HOME/polytoken-outpost/outpost-config.yaml (or
$HOME/.config/polytoken-outpost/outpost-config.yaml on systems without
XDG_CONFIG_HOME set). You can override the data directory with the
data_dir key.
The minimum valid configuration requires only an auth section. All fields
within auth have defaults, so an empty auth: block is sufficient to start
the server:
auth: {}A full configuration looks like this:
display_name: "Developer Box"listen: address: 0.0.0.0 port: 19870tls: cert_file: /etc/polytoken-outpost/cert.pem key_file: /etc/polytoken-outpost/key.pemauth: password: pepper_file: /etc/polytoken-outpost/pepper pam: service: polytoken-outpost session: idle_secs: 1800 absolute_secs: 28800 oauth: access_token_ttl_secs: 900 refresh_token_absolute_secs: 2592000 refresh_token_idle_secs: 1209600 device_code_ttl_secs: 900 device_poll_interval_secs: 5 rate_limit: max_attempts: 5 window_secs: 60 lockout_secs: 300 device_rate_limit: max_attempts: 10 window_secs: 300 lockout_secs: 60 external_url: https://outpost.example.com:8443 trusted_proxies: - 10.0.0.0/8network: allow_ips: - 10.0.0.10 - 10.0.0.11 deny_ips: - 10.0.0.99data_dir: /var/lib/polytoken-outpostdaemon_sessions: stop_timeout_secs: 30 health_check_timeout_secs: 30 spawn_discover_timeout_secs: 30 proxy_connect_timeout_secs: 30 auth_dir: /home/user/.local/share/polytoken/authConfiguration rejects unrecognized fields at parse time.
Listen address and port
Section titled “Listen address and port”| Key | Default | Description |
|---|---|---|
listen.address | Derived | IP address to bind. When auth.external_url is set, defaults to 0.0.0.0 (all interfaces). When unset and no auth provider requires network access, defaults to 127.0.0.1 (loopback). An explicit address always overrides the derived default. |
listen.port | 19870 | TCP port for the HTTP server. |
| Key | Default | Description |
|---|---|---|
tls.cert_file | none | Path to a PEM-encoded certificate file. Must be paired with tls.key_file. |
tls.key_file | none | Path to a PEM-encoded private key file. Must be paired with tls.cert_file. |
When both TLS paths are omitted, Outpost generates and persists a self-signed certificate on first start. See TLS below for the full resolution strategy.
Authentication
Section titled “Authentication”The auth section controls how clients authenticate. All sub-sections have
sensible defaults, so a minimal config with auth: {} starts the server with
password authentication enabled and PAM available where supported.
| Key | Default | Description |
|---|---|---|
auth.password.pepper_file | none | Path to a pepper file for Argon2id password hashing. When unset, Outpost loads the machine pepper from its data directory. |
auth.password.memory_kib | 19456 | Argon2id memory cost in KiB. |
auth.password.time_cost | 2 | Argon2id time cost (iterations). |
auth.password.parallelism | 1 | Argon2id parallelism (lanes). |
auth.password.max_password_bytes | 1024 | Maximum password length in bytes. Longer passwords are rejected. |
auth.password.max_concurrent_hashes | 4 | Maximum concurrent Argon2id hash operations. Prevents CPU exhaustion. |
auth.pam.service | polytoken-outpost | PAM service name. Outpost expects a matching entry in /etc/pam.d/. |
auth.session.idle_secs | 1800 | Web session idle timeout in seconds (30 minutes). |
auth.session.absolute_secs | 28800 | Web session absolute maximum lifetime in seconds (8 hours). |
auth.oauth.access_token_ttl_secs | 900 | OAuth access token TTL in seconds (15 minutes). |
auth.oauth.refresh_token_absolute_secs | 2592000 | OAuth refresh token absolute TTL in seconds (30 days). |
auth.oauth.refresh_token_idle_secs | 1209600 | OAuth refresh token idle timeout in seconds (14 days). |
auth.oauth.device_code_ttl_secs | 900 | Device code lifetime in seconds (15 minutes). |
auth.oauth.device_poll_interval_secs | 5 | Device flow polling interval in seconds. |
auth.rate_limit.max_attempts | 5 | Maximum failed login attempts per source IP within the window before lockout. |
auth.rate_limit.window_secs | 60 | Sliding window length in seconds for login rate-limit counting. |
auth.rate_limit.lockout_secs | 300 | Duration in seconds that a locked-out source IP must wait before retrying login. |
auth.device_rate_limit.max_attempts | 10 | Maximum device approval attempts per source IP within the window. |
auth.device_rate_limit.window_secs | 300 | Sliding window for device rate-limit counting. |
auth.device_rate_limit.lockout_secs | 60 | Duration in seconds for device flow lockout. |
auth.oauth_rate_limit.max_attempts | 20 | Maximum unauthenticated OAuth registration and device-authorization requests per source IP within the window. |
auth.oauth_rate_limit.window_secs | 300 | Sliding window for OAuth resource-creation rate-limit counting. |
auth.oauth_rate_limit.lockout_secs | 600 | Duration in seconds that a locked-out source IP must wait before retrying OAuth registration or device authorization. |
auth.oauth_token_rate_limit.max_attempts | 100 | Maximum token-endpoint requests (POST /oauth/token) per source IP within the window. More generous than the OAuth resource-creation limiter because legitimate device-code polling hits this endpoint at a sustained rate. |
auth.oauth_token_rate_limit.window_secs | 60 | Sliding window for token-endpoint rate-limit counting. |
auth.oauth_token_rate_limit.lockout_secs | 60 | Duration in seconds that a locked-out source IP must wait before retrying the token endpoint. |
auth.external_url | none | The externally reachable URL for this Outpost (for OAuth issuer metadata and .well-known responses). When set, the listen address defaults to 0.0.0.0. |
auth.trusted_proxies | empty | List of trusted proxy CIDRs. The X-Forwarded-For header is honored for client IP resolution only when the request originates from one of these CIDRs. |
Password changes revoke active access
Section titled “Password changes revoke active access”When you change your password through the web UI, Outpost immediately invalidates every other active access path for your account: all browser sessions except the one you used to submit the change, every OAuth grant and the access and refresh tokens derived from it, and all personal access tokens. This turns a password change into a full security reset. A stolen browser cookie, a previously issued delegated credential, or a compromised personal access token stops working the moment the new password takes effect.
The browser session you used to change the password stays active so you do not
need to log in again. If you use the CLI command polytoken outpost principal set-password instead of the web UI, existing sessions and tokens are not
revoked. That CLI path writes directly to the database and bypasses the
revocation logic.
Display name
Section titled “Display name”| Key | Default | Description |
|---|---|---|
display_name | (machine hostname) | Human-friendly name for this Outpost instance. Included in the /health response so clients can label the connection. When unset or empty, the server falls back to the machine hostname at startup. Control characters are rejected at validation time. |
Network filtering
Section titled “Network filtering”| Key | Default | Description |
|---|---|---|
network.allow_ips | empty | List of IP addresses permitted to connect. When non-empty, any IP not listed is rejected with 403 Forbidden. |
network.deny_ips | empty | List of IP addresses blocked from connecting. Deny always wins over allow. |
Outpost compares IP addresses by exact match. CIDR notation is not supported; list each address individually.
Daemon session settings
Section titled “Daemon session settings”| Key | Default | Description |
|---|---|---|
daemon_sessions.stop_timeout_secs | 30 | Seconds to wait for graceful shutdown before escalating to SIGKILL. |
daemon_sessions.health_check_timeout_secs | 30 | Timeout for the post-spawn health check poll. |
daemon_sessions.spawn_discover_timeout_secs | 30 | Timeout for port discovery after daemon spawn. |
daemon_sessions.proxy_connect_timeout_secs | 30 | Connect timeout for proxy requests to the daemon. |
daemon_sessions.auth_dir | (auto) | Override for the daemon’s auth directory. Passed as --auth-dir to spawned daemon processes so they can find auth profiles (Codex device auth, MCP OAuth). When unset, Polytoken auto-resolves from the host’s data_root()/auth/. |
CLI commands
Section titled “CLI commands”Outpost commands live under polytoken outpost.
Start the server
Section titled “Start the server”polytoken outpost serveThis loads the configuration, resolves paths, opens the database, loads the pepper, resolves TLS material, constructs the auth subsystems, and starts the HTTP server. The server runs in the foreground and handles SIGTERM for graceful shutdown.
Manage principals
Section titled “Manage principals”Principal commands write directly to the Outpost SQLite database. No running server is needed.
A principal is the identity unit in Outpost. Each principal has a username, an optional display name, and one or more credential bindings (password, PAM, or personal access tokens). You create principals and assign credentials to them.
# Create a principalpolytoken outpost principal create alice --display-name "Alice Smith"
# List all principalspolytoken outpost principal list
# Show a principal by ID or usernamepolytoken outpost principal show alice
# Rename a principal (update display name)polytoken outpost principal rename <id> --display-name "Alice Jones"
# Delete a principal (cascades to all credentials)polytoken outpost principal delete <id>Each principal can have one password credential and one PAM binding.
# Set or replace a principal's passwordpolytoken outpost principal set-password <id>
# Remove a principal's password credentialpolytoken outpost principal remove-password <id>
# Bind a principal to a PAM system userpolytoken outpost principal set-pam <id> --service polytoken-outpost --system-user alice
# Remove a principal's PAM bindingpolytoken outpost principal remove-pam <id>Manage personal access tokens
Section titled “Manage personal access tokens”Personal access tokens (PATs) are bearer tokens linked to a principal. They
allow automation tools to authenticate without storing a password. PAT tokens
use the prefix pat_.
# Create a personal access token for a principalpolytoken outpost token create <principal_id> --label ci-deploy
# List non-revoked tokens for a principalpolytoken outpost token list <principal_id>
# Revoke a token by IDpolytoken outpost token revoke <token_id>Token creation prints the full token to stdout exactly once. Polytoken stores only a SHA-256 hash of the token, never the token itself. Copy the token immediately; Polytoken cannot retrieve it later.
Manage OAuth clients
Section titled “Manage OAuth clients”Polytoken Outpost includes a built-in OAuth authorization server (RFC 6749,
7591, 8628, 7009, 8414). You manage OAuth client registrations with
polytoken outpost client subcommands. These commands write directly to the
Outpost SQLite database. No running server is needed.
# Register a public client (default)polytoken outpost client register my-app
# Register a confidential client with a specific grant typepolytoken outpost client register my-app --client-type confidential --grant-types client_credentials
# List all registered clientspolytoken outpost client list
# Delete a client by IDpolytoken outpost client delete <client-id>The first form registers a public client with the device_code grant type.
The second registers a confidential client. For confidential clients, the
command prints the generated client secret to stderr exactly once. Store it
securely. Polytoken stores only a SHA-256 hash of the secret, never the secret
itself.
Deleting a client revokes all its access tokens and refresh tokens before removing the record.
Query the audit log
Section titled “Query the audit log”The audit log records authentication events, token operations, and
administrative actions. The audit command reads directly from the SQLite
database.
# List recent audit eventspolytoken outpost audit list
# Filter by principalpolytoken outpost audit list --principal <principal_id>
# Filter events since a timestamppolytoken outpost audit list --since 2026-01-01T00:00:00Z
# Limit to 100 rowspolytoken outpost audit list --limit 100Manage configurations
Section titled “Manage configurations”Configuration management commands talk to a running Outpost server via HTTP.
They require the Outpost URL and a personal access token. Provide them with
--outpost-url and --outpost-token, or set the POLYTOKEN_OUTPOST_URL and
POLYTOKEN_OUTPOST_TOKEN environment variables.
# Register a configuration from a file (format inferred from extension)polytoken outpost config register myconfig --file ~/.config/polytoken/config.yaml \ --outpost-url http://127.0.0.1:19870 --outpost-token pat_...
# Register with an explicit formatpolytoken outpost config register myconfig --file config.toml --format toml
# List all registered configurationspolytoken outpost config list
# Show a configuration's details and raw contentpolytoken outpost config show myconfig
# Remove a configurationpolytoken outpost config remove myconfigWhen --format is omitted, the format is inferred from the file extension:
.yaml or .yml resolves to yaml, .toml resolves to toml. Any other
extension requires --format.
Management commands (config, project) resolve transport from the URL
scheme in --outpost-url. An http:// URL on a loopback address
(127.0.0.1, ::1, localhost) connects over plaintext. An https://
URL uses TLS trust-on-first-use: on the first connection, Polytoken prompts
you to accept and pin the server’s certificate. Plaintext HTTP is rejected
for non-loopback hosts.
polytoken connect always uses HTTPS, even for loopback targets. The
connect flow runs the same TLS trust-on-first-use check.
Manage projects
Section titled “Manage projects”Project management commands also talk to a running Outpost server. They use
the same --outpost-url and --outpost-token flags (or environment variables)
as configuration management.
# Register a project path with a configurationpolytoken outpost project register /path/to/project --config-name myconfig
# List all registered projectspolytoken outpost project list
# Remove a project by its IDpolytoken outpost project remove deadbeefThe path you provide is the literal filesystem path on the Outpost’s machine. Polytoken does not check whether the path exists on your local machine, because the Outpost server canonicalizes the path before storing it.
Authentication model
Section titled “Authentication model”Outpost authenticates clients through a principal-based system. A principal represents an identity that can authenticate via one or more credential types. The auth middleware classifies incoming bearer tokens by prefix and dispatches to the correct verification path.
Principals and credentials
Section titled “Principals and credentials”Every human caller is represented by a principal. A principal has a username and can hold multiple credential types simultaneously:
- Password credential: an Argon2id-hashed password stored in the database. Used by the web login form and interactive clients.
- PAM binding: links the principal to a system user authenticated through Linux PAM. Available on Linux with glibc only.
- Personal access tokens (PATs): bearer tokens (
pat_prefix) linked to the principal for automation. Polytoken stores a SHA-256 hash, not the token.
Machine callers authenticate through OAuth client credentials. They are not
represented as principals; instead, they receive an OutpostPrincipal of type
machine_client with the scopes granted at registration.
Token families
Section titled “Token families”The auth middleware classifies bearer tokens by their prefix to determine verification path:
| Prefix | Family | Verification |
|---|---|---|
pat_ | Personal access token | SHA-256 database lookup against the principal’s tokens |
oat_ | OAuth access token | SHA-256 database lookup against issued access tokens |
dsc_ | Daemon service credential | HMAC-SHA-256 with pepper, constant-time comparison |
| (other) | Unknown | Rejected with 401 Unauthorized |
Password authentication
Section titled “Password authentication”Password authentication uses Argon2id with a machine-local pepper. Polytoken
hashes passwords at creation time and stores only the hash. The pepper is
loaded from the data directory (or a custom path via
auth.password.pepper_file). Password verification is rate-limited per source
IP.
The web login form (/login) authenticates principals by username and
password. On success, Polytoken sets a session cookie that authorizes
subsequent web requests. Session cookies expire after the configured idle
timeout and absolute lifetime.
PAM authentication (Linux glibc only)
Section titled “PAM authentication (Linux glibc only)”The PAM provider authenticates clients against Linux Pluggable Authentication Modules. A principal with a PAM binding can authenticate through the web login form using their system username and password. Polytoken delegates to PAM via the configured service name and resolves the matching principal.
PAM requires a service entry at /etc/pam.d/polytoken-outpost (or the name
configured in auth.pam.service). You must create this entry before PAM
authentication can succeed.
Outpost includes a full OAuth 2.0 authorization server supporting RFC 6749 (Authorization Framework), RFC 7591 (Dynamic Client Registration), RFC 8628 (Device Authorization Grant), RFC 7009 (Token Revocation), and RFC 8414 (Authorization Server Metadata).
OAuth supports two grant types:
- Device authorization grant (RFC 8628): public clients (CLI tools, mobile
apps) initiate a device flow. Outpost displays a user code in the web UI at
/device. An authenticated human principal approves or denies the request. On approval, the client receives an access token (oat_prefix). - Client credentials grant (RFC 6749 §4.4): confidential clients authenticate with their client ID and secret to receive an access token directly. No human interaction required.
Access tokens expire after auth.oauth.access_token_ttl_secs (default 15
minutes). Refresh tokens rotate on each use and expire after both an absolute
and idle timeout. Refresh token family reuse is detected and revokes the entire
family.
The OAuth metadata is published at
/.well-known/oauth-authorization-server per RFC 8414.
Device-flow token scopes
Section titled “Device-flow token scopes”When a client uses the OAuth device authorization grant (RFC 8628), the
resulting access token carries the scopes approved by the operator on the
device approval page. Outpost enforces those scopes on every scope-gated
route. A device token approved for only the project scope is denied on
management routes (configurations, projects, sessions) with 403 Forbidden,
because every management route requires the machine scope. A device token
approved for machine passes on management routes.
If the client omits the scope parameter in the device authorization request,
Outpost grants all scopes registered for the client. A device-flow token
issued with no requested scope has full management access. This matches the
OAuth 2.0 device flow specification (RFC 8628 defers to RFC 6749 for scope
handling) and the client-credentials grant behavior.
To constrain a device client’s access, request narrow scopes at
device-authorization time. For example, requesting scope=project limits the
token to project scope only. Outpost validates requested scopes against the
client’s registered scopes and rejects unregistered scopes with an
invalid_scope error.
Rate limiting
Section titled “Rate limiting”Outpost limits failed authentication attempts per source IP. Three independent rate limiters exist:
- Login rate limiter (
auth.rate_limit): applies to password and web login attempts. Runs before the credential check, so a locked-out IP never reaches the password hasher. Aftermax_attemptsfailures withinwindow_secs, Outpost locks the IP forlockout_secs. - Device rate limiter (
auth.device_rate_limit): applies to device approval and denial attempts. Independent thresholds prevent device flow abuse from affecting login availability. - OAuth resource limiter (
auth.oauth_rate_limit): bounds unauthenticatedPOST /oauth/registerandPOST /oauth/device_authorizationper source IP. The budget is shared across both endpoints: a normal device does one register and one device-authorization request, so the default of 20 allows approximately 10 devices per window. Unlike the login and device limiters, this limiter counts every request regardless of outcome (a successful registration consumes budget just as a failed one does).
Rate limiting isolates by source IP. One IP hitting the limit does not prevent a different IP from authenticating. Internal errors (database failures, PAM system errors) do not count toward rate limiting.
IPv6 source addresses are normalized to their /64 network prefix before
keying, so all addresses within a single /64 allocation share one rate-limit
budget. IPv4 addresses are keyed in full. IPv4-mapped IPv6 addresses
(::ffff:a.b.c.d) are canonicalized to their IPv4 equivalent. This applies to
all three rate limiters uniformly.
When auth.trusted_proxies is configured, the rate limiter uses the real
client IP resolved from X-Forwarded-For instead of the direct TCP peer.
Fail-closed behavior
Section titled “Fail-closed behavior”When the authentication system encounters an internal error (such as a
database failure or a PAM system error), Outpost returns
503 Service Un Available. Internal errors never count toward rate limiting,
and the system never leaks diagnostic detail to the client.
Web UI
Section titled “Web UI”Outpost includes a built-in web interface for browser-based access. The web UI provides a dashboard where authenticated principals can:
- Manage personal access tokens (create, list, revoke).
- Change their password.
- View and revoke active OAuth grants.
- View and revoke active web sessions.
- Approve or deny pending device authorization requests.
The web UI uses cookie-based sessions with CSRF protection. Login accepts a username and password (or PAM credentials). Sessions expire after the configured idle and absolute timeouts.
Web routes are served at the root path (not under /api/v1). They use their
own middleware stack: security headers, session cookie management, and CSRF
token validation.
HTTP API
Section titled “HTTP API”Outpost exposes HTTP routes under the /api/v1 prefix. API routes require a
bearer token in the Authorization header. OAuth endpoints and the
.well-known discovery endpoint are exceptions: OAuth endpoints live at
/api/v1/oauth/* and do not require bearer tokens (they use their own client
authentication), while .well-known/oauth-authorization-server lives at the
root path.
All routes are subject to source-IP filtering. The root web UI routes use cookie-based session auth rather than bearer tokens.
Core routes
Section titled “Core routes”| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/health | Bearer token | Returns server status, whether the database was migrated at startup, and the display name (name field). |
GET | /api/v1/version | Bearer token | Returns the Outpost version and compiled SQLite version. |
GET | /api/v1/openapi.json | Bearer token | Returns the compiled OpenAPI specification as JSON. |
GET | /api/v1/configurations | Bearer token | Lists all configurations with metadata. |
POST | /api/v1/configurations | Bearer token | Creates a new configuration with file-backed storage. |
GET | /api/v1/configurations/:name | Bearer token | Returns the full configuration detail including raw content. |
PUT | /api/v1/configurations/:name | Bearer token | Updates an existing configuration (partial update). |
DELETE | /api/v1/configurations/:name | Bearer token | Deletes a configuration and its files. Returns 409 if projects are bound. |
Project registry routes
Section titled “Project registry routes”The project registry manages filesystem paths bound to named configurations. Each project is addressed by an opaque 8-character hexadecimal ID generated at registration time.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/projects | Bearer token | Lists all registered projects with derived hook statuses. |
POST | /api/v1/projects | Bearer token | Registers a project. Requires a filesystem path and a configuration name. |
GET | /api/v1/projects/:id | Bearer token | Fetches a single project by its ID. |
PUT | /api/v1/projects/:id | Bearer token | Rebinds a project to a different configuration. |
DELETE | /api/v1/projects/:id | Bearer token | Deletes (unregisters) a project. |
Registration
Section titled “Registration”To register a project, send a POST /api/v1/projects request with a JSON body:
{ "path": "/home/user/my-project", "config_name": "default"}Polytoken canonicalizes the path using std::fs::canonicalize, which resolves
symlinks and collapses . and .. components. The stored path is the
fully-resolved absolute form, which may differ from the string you submitted.
If the directory does not exist, registration fails with 422.
The config_name must reference an existing configuration in the registry. If
the configuration does not exist, registration fails with 422.
On success, the response is 201 Created with the project record:
{ "id": "a1b2c3d4", "path": "/home/user/my-project", "config_name": "default", "registered_at": "2026-07-06T12:00:00+00:00", "updated_at": "2026-07-06T12:00:00+00:00", "hook_status": { "has_outpost_yaml": false, "configured_hooks": [] }}Because canonical paths are unique, registering the same directory twice (even
via different path expressions, such as a symlink) returns 409 Conflict.
Updating a project
Section titled “Updating a project”Send a PUT /api/v1/projects/:id request to rebind a project to a new
configuration:
{ "config_name": "production"}If the project or the configuration does not exist, the response is 404.
Hook status
Section titled “Hook status”Every project response includes a hook_status field derived at query time by
reading .polytoken/outpost.yaml inside the project directory. When the file
exists and parses successfully, has_outpost_yaml is true and
configured_hooks lists the hook names. When the file is absent, unreadable,
or fails to parse, has_outpost_yaml is false and configured_hooks is
empty. Hook status is best-effort metadata and never causes a request to fail.
Session lifecycle routes
Section titled “Session lifecycle routes”The session lifecycle API manages Polytoken daemon processes through Outpost.
Each session belongs to a registered project and progresses through a state
machine: starting, running, stopped, failed, cleanup_pending,
reaped.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/sessions | Bearer token | Lists all sessions. |
POST | /api/v1/sessions | Bearer token | Creates a new session for a project. |
GET | /api/v1/sessions/:id | Bearer token | Fetches a session by ID with transition history. |
POST | /api/v1/sessions/:id/stop | Bearer token | Stops a running session. |
POST | /api/v1/sessions/:id/resume | Bearer token | Resumes a stopped or failed session. |
DELETE | /api/v1/sessions/:id | Bearer token | Reaps a session: stops the daemon, destroys the worktree, and deletes the record. |
| All | /api/v1/sessions/:id/proxy/* | Bearer token | Transparent forward to the running session’s daemon. |
Creating a session
Section titled “Creating a session”Send a POST /api/v1/sessions request with the project ID and a launch mode:
{ "project_id": "a1b2c3d4", "launch_mode": "project_direct"}The launch_mode field accepts "project_direct" (runs the daemon in the
registered project directory) or "worktree_backed" (creates a git worktree
first). If the project does not exist, the response is 404. If the launch
mode is invalid, the response is 422.
On success, the response is 201 Created with the session record. The session
starts in the starting state. Outpost spawns the daemon process in the
background and transitions the session to running once the daemon is healthy.
Session states
Section titled “Session states”| State | Description |
|---|---|
starting | The daemon process has been spawned but is not yet healthy. |
running | The daemon is listening and accepting requests. |
stopped | The daemon was stopped by the operator or a lifecycle event. |
failed | The daemon crashed or failed a health check. |
cleanup_pending | The session is being cleaned up (worktree destruction in progress). |
reaped | The session record is about to be deleted. This state is transient. |
Getting session detail
Section titled “Getting session detail”GET /api/v1/sessions/:id returns the session record with its full transition
history and credential audit trail. For sessions in the running state,
Outpost performs an on-demand health check. If the daemon is unreachable,
the session transitions to failed before the response is returned. If the
session does not exist, the response is 404.
The response includes a service_credentials array with non-secret metadata
for each daemon service credential minted for the session. Each entry
contains:
generation_id: the credential generation identifier.state:pending,active, orinvalidated.created_at,activated_at,invalidated_at,last_used_at: RFC 3339 timestamps (the latter three arenullwhen not yet applicable).invalidation_cause: a human-readable reason string for invalidated credentials, such assession_stopped,watcher_daemon_exit, orreconciliation. This field isnullfor credentials that have not been invalidated.
Polytoken never exposes the bearer token, verifier, salt, or capability snapshot in this array. Only lifecycle metadata is returned, so you can audit credential rotation without exposing secrets.
Stopping a session
Section titled “Stopping a session”POST /api/v1/sessions/:id/stop sends an HTTP terminate request to the daemon,
followed by a SIGTERM escalation if the daemon does not respond. The session
transitions to stopped on success. Stopping a session that is not running
returns 409 Conflict. If the session does not exist, the response is 404.
Resuming a session
Section titled “Resuming a session”POST /api/v1/sessions/:id/resume launches a fresh daemon for a stopped or
failed session. The session transitions back to running. If the session is
already running, the response is 409 Conflict.
Reaping a session
Section titled “Reaping a session”DELETE /api/v1/sessions/:id stops the daemon if it is running, destroys the
worktree (for worktree_backed sessions), and deletes the session record.
On success, the response is 204 No Content. If the session does not exist,
the response is 404.
Session proxy
Section titled “Session proxy”The /api/v1/sessions/:id/proxy/* route transparently forwards HTTP requests
and Server-Sent Events (SSE) streams to a running session’s daemon. You
authenticate to Outpost with your normal Outpost bearer token. Outpost
replaces it with the daemon’s internal bearer token before forwarding, so the
client never sees the daemon credential.
The proxy forwards any HTTP method and any path under the daemon’s control.
This includes health checks (GET /health), prompts (POST /prompt), SSE
streams (GET /events), and all other daemon routes. Responses pass through
without modification: status codes, headers, and body bytes are forwarded as
received from the daemon. Multiple clients can proxy to the same session
concurrently.
The proxy is excluded from the OpenAPI specification. It is a transparent passthrough, not a documented API surface.
Error responses from the proxy itself:
| Status | Condition |
|---|---|
| 404 | The session ID does not exist or was already reaped. |
| 503 | The session exists but is not in the running state. The response body names the current state. |
| 502 | The daemon is unreachable (the session is marked running but the process is not listening). |
OAuth routes
Section titled “OAuth routes”OAuth endpoints implement standard OAuth 2.0 flows. They do not require a bearer token; they use their own client authentication (client secret, client credentials, or the device flow user-approval mechanism).
| Method | Path | Description |
|---|---|---|
POST | /api/v1/oauth/register | Dynamic client registration (RFC 7591). |
POST | /api/v1/oauth/device_authorization | Initiate device authorization grant (RFC 8628). |
POST | /api/v1/oauth/token | Issue or refresh an access token (RFC 6749). |
POST | /api/v1/oauth/revoke | Revoke an access or refresh token (RFC 7009). |
GET | /.well-known/oauth-authorization-server | Authorization server metadata (RFC 8414). |
All OAuth responses include Cache-Control: no-store. Token responses follow
the standard JSON shape with access_token, token_type, expires_in, and
optional refresh_token fields.
Services routes
Section titled “Services routes”Outpost exposes routes that let a running daemon discover the service identity and capabilities available to it:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/v1/services/identity | Bearer token | Returns the Outpost service identity metadata. |
GET | /api/v1/services/capabilities | Bearer token | Returns the configured service catalog. |
GET | /api/v1/services/readiness | Bearer token | Returns the services subsystem readiness state. |
These routes are used internally by the daemon during its startup bootstrap sequence. The daemon receives a bootstrap file at launch containing its service bearer token and the Outpost’s TLS fingerprint. The daemon reads and deletes this file at startup. The bootstrap file contains the bearer token needed to call the services routes.
The services config block controls which services are enabled and what
capabilities each service exposes. By default, no services are enabled:
services: conversations: enabled: true capabilities: search: enabled: true modes: ["lexical"]The daemon service credential lifecycle has three states: pending, active,
and invalidated. When a session stops or fails, its active credential is
invalidated. Each invalidation records a human-readable cause visible in the
session detail audit array. A credential from a previous daemon generation is
rejected when presented for authentication.
Authorization header
Section titled “Authorization header”Protected API routes require an Authorization: Bearer <token> header. The
token can be:
- A personal access token (
pat_...) created viapolytoken outpost token create. - An OAuth access token (
oat_...) obtained through the OAuth token endpoint. - A daemon service credential (
dsc_...) provided through the bootstrap file.
Configuration management
Section titled “Configuration management”Outpost stores Polytoken configurations on disk under
$DATA_ROOT/configurations/<name>/. Each configuration has a config file
(config.yaml or config.toml) and an optional pre-run script
(pre-run.sh). Metadata is tracked in the SQLite database, and file
operations are coordinated with database state through a compensation model:
if a file write fails after a database insert, the database record is rolled
back.
Creating a configuration (POST /api/v1/configurations):
The request body contains the configuration name, raw content, format, and an optional pre-run script. The name must be slug-safe (lowercase letters, digits, and hyphens, not starting or ending with a hyphen). The content is parsed and validated against the declared format before storage.
{ "name": "ci-pipeline", "config_content": "key: value\n", "config_format": "yaml", "pre_run_script": "#!/bin/sh\necho hello"}On success, returns 201 Created with a ConfigurationResponse body. Returns
409 Conflict if a configuration with that name already exists, 422 Unprocessable Entity for validation errors (invalid name, unparseable
content, unsupported format), or 503 Service Unavailable on internal
errors.
Listing configurations (GET /api/v1/configurations):
Returns 200 OK with an array of ConfigurationResponse objects. Each entry
includes the name, format, timestamps, the number of bound projects, and
whether a pre-run script exists.
Getting configuration detail (GET /api/v1/configurations/:name):
Returns 200 OK with a ConfigurationDetailResponse that includes the raw
configuration file content. Returns 404 Not Found for unknown names.
Invalid names (path traversal attempts, uppercase, special characters) are
rejected with 422 before any database or filesystem access.
Updating a configuration (PUT /api/v1/configurations/:name):
Supports partial updates. All fields are optional, but at least one must be provided. When changing the format, you must also provide new content in the target format. Format changes write the new file first, update the database, then remove the old file, ensuring the system is never in a broken state if the database update fails.
{ "config_content": "key = \"value\"\n", "config_format": "toml"}To update only the pre-run script, omit content and format. Setting
pre_run_script to an empty string deletes the existing script.
Deleting a configuration (DELETE /api/v1/configurations/:name):
Returns 204 No Content on success. The database record is removed first,
then the configuration directory is deleted from disk (best-effort). Returns
409 Conflict if the configuration has bound projects, preventing removal
while projects depend on it.
Outpost resolves TLS material through the following strategy:
- If both
tls.cert_fileandtls.key_fileare set, Outpost loads the operator-provided files. If only one is set, Outpost refuses to start. - If neither is set, Outpost checks for an existing self-signed certificate under its data directory. If both cert and key files exist, Outpost loads them.
- If no certificate exists, Outpost generates a self-signed certificate with a
common name of
Polytoken Outpost for <hostname>, writes the cert (mode0644) and key (mode0600) to disk, and loads them. The certificate persists across restarts without regeneration.
A non-loopback listen address always requires TLS. Loopback permits plaintext
at the operator’s option, which is the default behavior when no TLS files are
configured and the listen address is 127.0.0.1.
Security model
Section titled “Security model”Outpost follows a fail-closed security posture. If the authentication system
fails for any internal reason, Outpost returns 503 Service Unavailable rather
than letting the request through.
All authentication failures produce a byte-identical 401 Unauthorized
response body: {"error":"unauthorized"}. Whether the token is missing,
malformed, revoked, expired, or simply unknown, the response is the same. This
prevents an attacker from distinguishing failure reasons through response body
or timing analysis.
Polytoken never writes tokens, secrets, or hashes to logs or error messages.
The token list command returns only non-secret metadata (label, creation
time, last-used time, revocation status). Polytoken stores only SHA-256 hashes
of personal access tokens and OAuth access tokens, never the tokens
themselves.
The daemon service credential uses a separate token family prefixed with
dsc_. These tokens are distinct from personal access tokens and OAuth
tokens. The daemon receives its service credential through a bootstrap file at
launch, reads the file, and deletes it immediately. If the daemon exits before
consuming the file, Outpost removes it as part of session cleanup. A service
credential from a previous daemon generation is rejected when a new daemon
starts, preventing stale tokens from accessing the services routes.
Platform requirements
Section titled “Platform requirements”PAM authentication is available on Linux with glibc only. Outpost builds without PAM support on Linux musl, macOS, and all other supported platforms. On those platforms, only password and token-based authentication are available.
Password authentication and personal access tokens work on all supported platforms.
Connecting to an Outpost
Section titled “Connecting to an Outpost”The sections above cover running an Outpost server. This section covers the
client side: connecting to a remote Outpost from your own machine using the
polytoken connect command.
The connect command
Section titled “The connect command”polytoken connect <target> connects to a running Outpost server,
authenticates, lets you pick or create a session, and opens the conversation
interface attached to that session through the Outpost proxy. The target
argument is a host or host:port pair. When you omit the port, Polytoken
uses the default port 19870.
polytoken connect outpost.example.compolytoken connect 10.0.0.5:8443polytoken connect [::1]:9090The conversation interface that opens behaves exactly like a local session. All prompts, tools, and slash commands work the same way. The difference is in how you disconnect, described in Detach versus kill session below.
TLS trust-on-first-use
Section titled “TLS trust-on-first-use”When you connect to a non-loopback host, Polytoken first tries standard certificate-authority (CA) validation. If the server uses a self-signed certificate, which is the Outpost default, Polytoken displays the certificate fingerprint and common name and asks whether to trust the certificate.
If you accept, Polytoken stores the fingerprint locally and trusts that specific certificate on every subsequent connection to the same host. If the certificate changes on a later connection, Polytoken asks you to confirm the new fingerprint before proceeding. This approach is called trust-on-first-use (TOFU).
TOFU pins take priority over CA validation. When you connect to a host that already has a stored pin, Polytoken skips CA validation entirely and compares the presented certificate against the pin. A certificate that differs from the stored pin triggers a mismatch warning, even if the new certificate is signed by a standard CA. You must explicitly accept the new certificate to rotate the pin.
Servers that use short-lived CA-issued certificates, such as Let’s Encrypt which expires every 90 days, will trigger a mismatch prompt on each renewal if you previously pinned a different certificate for that host. For automated workflows that cannot present an interactive prompt, the connection declines the new certificate by default. You must manually accept the rotation or re-establish the pin.
A pinned certificate that has expired still fails the TLS handshake, even after you accepted the fingerprint. Polytoken trusts the pinned certificate material but does not disable expiry checks or hostname verification.
Fingerprint storage
Section titled “Fingerprint storage”Polytoken stores pinned fingerprints under
$XDG_DATA_HOME/polytoken/outpost-client/fingerprints/ (or
~/.local/share/polytoken/outpost-client/fingerprints/ on systems without
XDG_DATA_HOME). Each file is named by a hash of the hostname and contains
the certificate fingerprint. Fingerprint pins are per-hostname, not per-port,
because a single host typically serves the same certificate across ports.
Plaintext for loopback connections
Section titled “Plaintext for loopback connections”When the target is a loopback address (127.0.0.1, ::1, or localhost),
Polytoken uses plaintext HTTP and skips TLS trust entirely. This matches the
Outpost server policy that permits plaintext for loopback binds. Every
non-loopback connection requires TLS.
Authentication
Section titled “Authentication”Polytoken discovers the server authentication method automatically before prompting you.
For password-configured servers, Polytoken prompts for a username and password. For PAM-configured servers, Polytoken prompts for a system username and password, authenticates against the remote machine, and receives a session token. In both cases, the resulting bearer token authorizes all subsequent requests through the session proxy.
Session picker
Section titled “Session picker”After authentication, Polytoken shows a full-screen picker with two tables: existing sessions and registered projects. Press Tab to switch between the two tables.
In the Sessions table, press Enter to attach to an existing session. Polytoken resumes the session automatically if it is stopped. In the Projects table, press Enter to create a new session for the selected project. Press n to jump from the Sessions table to the Projects table. Press r to refresh both lists. Project paths in the Projects table are abbreviated to their last two directory segments for readability.
Press Esc to go back from the Projects table to the Sessions table. Press Esc from the Sessions table to cancel and disconnect. Ctrl+C cancels from either table.
Detach versus kill session
Section titled “Detach versus kill session”When you connect through an Outpost, the daemon runs on the remote machine and stays alive after you disconnect. Two lifecycle commands control what happens to that daemon:
Detach (Ctrl+D or /exit) disconnects your terminal from the remote
session but leaves the daemon running on the remote machine. The session
keeps its context and conversation history. You can reconnect later with
polytoken connect and pick up where you left off.
Kill session (/kill-session) stops the daemon on the remote machine and
then detaches. Polytoken opens a confirmation dialog before stopping the
session. The dialog defaults to Cancel so you do not stop the session
accidentally.
In Outpost mode, a single press of Ctrl+C detaches immediately. This differs from a local session, where Ctrl+C cancels the active turn and a second press is needed to confirm termination. The difference exists because terminating a local daemon destroys work in progress, while detaching from a remote session preserves it. The remote daemon continues running either way.