Skip to content

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.

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.

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: 19870
tls:
cert_file: /etc/polytoken-outpost/cert.pem
key_file: /etc/polytoken-outpost/key.pem
auth:
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/8
network:
allow_ips:
- 10.0.0.10
- 10.0.0.11
deny_ips:
- 10.0.0.99
data_dir: /var/lib/polytoken-outpost
daemon_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/auth

Configuration rejects unrecognized fields at parse time.

KeyDefaultDescription
listen.addressDerivedIP 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.port19870TCP port for the HTTP server.
KeyDefaultDescription
tls.cert_filenonePath to a PEM-encoded certificate file. Must be paired with tls.key_file.
tls.key_filenonePath 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.

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.

KeyDefaultDescription
auth.password.pepper_filenonePath to a pepper file for Argon2id password hashing. When unset, Outpost loads the machine pepper from its data directory.
auth.password.memory_kib19456Argon2id memory cost in KiB.
auth.password.time_cost2Argon2id time cost (iterations).
auth.password.parallelism1Argon2id parallelism (lanes).
auth.password.max_password_bytes1024Maximum password length in bytes. Longer passwords are rejected.
auth.password.max_concurrent_hashes4Maximum concurrent Argon2id hash operations. Prevents CPU exhaustion.
auth.pam.servicepolytoken-outpostPAM service name. Outpost expects a matching entry in /etc/pam.d/.
auth.session.idle_secs1800Web session idle timeout in seconds (30 minutes).
auth.session.absolute_secs28800Web session absolute maximum lifetime in seconds (8 hours).
auth.oauth.access_token_ttl_secs900OAuth access token TTL in seconds (15 minutes).
auth.oauth.refresh_token_absolute_secs2592000OAuth refresh token absolute TTL in seconds (30 days).
auth.oauth.refresh_token_idle_secs1209600OAuth refresh token idle timeout in seconds (14 days).
auth.oauth.device_code_ttl_secs900Device code lifetime in seconds (15 minutes).
auth.oauth.device_poll_interval_secs5Device flow polling interval in seconds.
auth.rate_limit.max_attempts5Maximum failed login attempts per source IP within the window before lockout.
auth.rate_limit.window_secs60Sliding window length in seconds for login rate-limit counting.
auth.rate_limit.lockout_secs300Duration in seconds that a locked-out source IP must wait before retrying login.
auth.device_rate_limit.max_attempts10Maximum device approval attempts per source IP within the window.
auth.device_rate_limit.window_secs300Sliding window for device rate-limit counting.
auth.device_rate_limit.lockout_secs60Duration in seconds for device flow lockout.
auth.oauth_rate_limit.max_attempts20Maximum unauthenticated OAuth registration and device-authorization requests per source IP within the window.
auth.oauth_rate_limit.window_secs300Sliding window for OAuth resource-creation rate-limit counting.
auth.oauth_rate_limit.lockout_secs600Duration in seconds that a locked-out source IP must wait before retrying OAuth registration or device authorization.
auth.oauth_token_rate_limit.max_attempts100Maximum 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_secs60Sliding window for token-endpoint rate-limit counting.
auth.oauth_token_rate_limit.lockout_secs60Duration in seconds that a locked-out source IP must wait before retrying the token endpoint.
auth.external_urlnoneThe 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_proxiesemptyList 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.

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.

KeyDefaultDescription
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.
KeyDefaultDescription
network.allow_ipsemptyList of IP addresses permitted to connect. When non-empty, any IP not listed is rejected with 403 Forbidden.
network.deny_ipsemptyList 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.

KeyDefaultDescription
daemon_sessions.stop_timeout_secs30Seconds to wait for graceful shutdown before escalating to SIGKILL.
daemon_sessions.health_check_timeout_secs30Timeout for the post-spawn health check poll.
daemon_sessions.spawn_discover_timeout_secs30Timeout for port discovery after daemon spawn.
daemon_sessions.proxy_connect_timeout_secs30Connect 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/.

Outpost commands live under polytoken outpost.

Terminal window
polytoken outpost serve

This 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.

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.

Terminal window
# Create a principal
polytoken outpost principal create alice --display-name "Alice Smith"
# List all principals
polytoken outpost principal list
# Show a principal by ID or username
polytoken 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.

Terminal window
# Set or replace a principal's password
polytoken outpost principal set-password <id>
# Remove a principal's password credential
polytoken outpost principal remove-password <id>
# Bind a principal to a PAM system user
polytoken outpost principal set-pam <id> --service polytoken-outpost --system-user alice
# Remove a principal's PAM binding
polytoken outpost principal remove-pam <id>

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_.

Terminal window
# Create a personal access token for a principal
polytoken outpost token create <principal_id> --label ci-deploy
# List non-revoked tokens for a principal
polytoken outpost token list <principal_id>
# Revoke a token by ID
polytoken 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.

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.

Terminal window
# Register a public client (default)
polytoken outpost client register my-app
# Register a confidential client with a specific grant type
polytoken outpost client register my-app --client-type confidential --grant-types client_credentials
# List all registered clients
polytoken outpost client list
# Delete a client by ID
polytoken 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.

The audit log records authentication events, token operations, and administrative actions. The audit command reads directly from the SQLite database.

Terminal window
# List recent audit events
polytoken outpost audit list
# Filter by principal
polytoken outpost audit list --principal <principal_id>
# Filter events since a timestamp
polytoken outpost audit list --since 2026-01-01T00:00:00Z
# Limit to 100 rows
polytoken outpost audit list --limit 100

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.

Terminal window
# 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 format
polytoken outpost config register myconfig --file config.toml --format toml
# List all registered configurations
polytoken outpost config list
# Show a configuration's details and raw content
polytoken outpost config show myconfig
# Remove a configuration
polytoken outpost config remove myconfig

When --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.

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.

Terminal window
# Register a project path with a configuration
polytoken outpost project register /path/to/project --config-name myconfig
# List all registered projects
polytoken outpost project list
# Remove a project by its ID
polytoken outpost project remove deadbeef

The 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.

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.

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.

The auth middleware classifies bearer tokens by their prefix to determine verification path:

PrefixFamilyVerification
pat_Personal access tokenSHA-256 database lookup against the principal’s tokens
oat_OAuth access tokenSHA-256 database lookup against issued access tokens
dsc_Daemon service credentialHMAC-SHA-256 with pepper, constant-time comparison
(other)UnknownRejected with 401 Unauthorized

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.

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.

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.

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. After max_attempts failures within window_secs, Outpost locks the IP for lockout_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 unauthenticated POST /oauth/register and POST /oauth/device_authorization per 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.

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.

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.

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.

MethodPathAuthDescription
GET/api/v1/healthBearer tokenReturns server status, whether the database was migrated at startup, and the display name (name field).
GET/api/v1/versionBearer tokenReturns the Outpost version and compiled SQLite version.
GET/api/v1/openapi.jsonBearer tokenReturns the compiled OpenAPI specification as JSON.
GET/api/v1/configurationsBearer tokenLists all configurations with metadata.
POST/api/v1/configurationsBearer tokenCreates a new configuration with file-backed storage.
GET/api/v1/configurations/:nameBearer tokenReturns the full configuration detail including raw content.
PUT/api/v1/configurations/:nameBearer tokenUpdates an existing configuration (partial update).
DELETE/api/v1/configurations/:nameBearer tokenDeletes a configuration and its files. Returns 409 if projects are bound.

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.

MethodPathAuthDescription
GET/api/v1/projectsBearer tokenLists all registered projects with derived hook statuses.
POST/api/v1/projectsBearer tokenRegisters a project. Requires a filesystem path and a configuration name.
GET/api/v1/projects/:idBearer tokenFetches a single project by its ID.
PUT/api/v1/projects/:idBearer tokenRebinds a project to a different configuration.
DELETE/api/v1/projects/:idBearer tokenDeletes (unregisters) a project.

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.

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.

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.

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.

MethodPathAuthDescription
GET/api/v1/sessionsBearer tokenLists all sessions.
POST/api/v1/sessionsBearer tokenCreates a new session for a project.
GET/api/v1/sessions/:idBearer tokenFetches a session by ID with transition history.
POST/api/v1/sessions/:id/stopBearer tokenStops a running session.
POST/api/v1/sessions/:id/resumeBearer tokenResumes a stopped or failed session.
DELETE/api/v1/sessions/:idBearer tokenReaps a session: stops the daemon, destroys the worktree, and deletes the record.
All/api/v1/sessions/:id/proxy/*Bearer tokenTransparent forward to the running session’s daemon.

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.

StateDescription
startingThe daemon process has been spawned but is not yet healthy.
runningThe daemon is listening and accepting requests.
stoppedThe daemon was stopped by the operator or a lifecycle event.
failedThe daemon crashed or failed a health check.
cleanup_pendingThe session is being cleaned up (worktree destruction in progress).
reapedThe session record is about to be deleted. This state is transient.

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, or invalidated.
  • created_at, activated_at, invalidated_at, last_used_at: RFC 3339 timestamps (the latter three are null when not yet applicable).
  • invalidation_cause: a human-readable reason string for invalidated credentials, such as session_stopped, watcher_daemon_exit, or reconciliation. This field is null for 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.

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.

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.

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.

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:

StatusCondition
404The session ID does not exist or was already reaped.
503The session exists but is not in the running state. The response body names the current state.
502The daemon is unreachable (the session is marked running but the process is not listening).

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).

MethodPathDescription
POST/api/v1/oauth/registerDynamic client registration (RFC 7591).
POST/api/v1/oauth/device_authorizationInitiate device authorization grant (RFC 8628).
POST/api/v1/oauth/tokenIssue or refresh an access token (RFC 6749).
POST/api/v1/oauth/revokeRevoke an access or refresh token (RFC 7009).
GET/.well-known/oauth-authorization-serverAuthorization 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.

Outpost exposes routes that let a running daemon discover the service identity and capabilities available to it:

MethodPathAuthDescription
GET/api/v1/services/identityBearer tokenReturns the Outpost service identity metadata.
GET/api/v1/services/capabilitiesBearer tokenReturns the configured service catalog.
GET/api/v1/services/readinessBearer tokenReturns 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.

Protected API routes require an Authorization: Bearer <token> header. The token can be:

  • A personal access token (pat_...) created via polytoken outpost token create.
  • An OAuth access token (oat_...) obtained through the OAuth token endpoint.
  • A daemon service credential (dsc_...) provided through the bootstrap file.

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:

  1. If both tls.cert_file and tls.key_file are set, Outpost loads the operator-provided files. If only one is set, Outpost refuses to start.
  2. 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.
  3. If no certificate exists, Outpost generates a self-signed certificate with a common name of Polytoken Outpost for <hostname>, writes the cert (mode 0644) and key (mode 0600) 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.

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.

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.

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.

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.

Terminal window
polytoken connect outpost.example.com
polytoken connect 10.0.0.5:8443
polytoken connect [::1]:9090

The 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.

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.

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.

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.

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.

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.

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.