openapi: "3.1.0" info: title: Wrenn API description: MicroVM-based code execution platform API. version: "0.1.0" servers: - url: http://localhost:8080 description: Local development security: [] paths: /v1/auth/signup: post: summary: Create a new account operationId: signup tags: [auth] description: | Creates an inactive user account and sends an activation email. The user must activate their account within 30 minutes. Does not return a JWT — the user must activate first, then sign in. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SignupRequest" responses: "201": description: Account created, activation email sent content: application/json: schema: $ref: "#/components/schemas/SignupResponse" "400": description: Invalid request (bad email, short password) content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Email already registered or signup cooldown active content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/activate: post: summary: Activate account via email token operationId: activate tags: [auth] description: | Consumes the activation token sent via email and activates the user account. Creates a default team and returns a JWT to log the user in. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: type: string responses: "200": description: Account activated, JWT issued content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "400": description: Invalid or expired token content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/switch-team: post: summary: Switch active team operationId: switchTeam tags: [auth] security: - bearerAuth: [] description: | Re-issues a JWT scoped to a different team. The user must be a member of the target team (verified from DB). Use the returned token for subsequent requests to that team's resources. requestBody: required: true content: application/json: schema: type: object required: [team_id] properties: team_id: type: string responses: "200": description: New JWT issued for the target team content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "403": description: Not a member of this team content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Team not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/login: post: summary: Log in with email and password operationId: login tags: [auth] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LoginRequest" responses: "200": description: Login successful content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "401": description: Invalid credentials content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/oauth/{provider}: parameters: - name: provider in: path required: true schema: type: string enum: [github] description: OAuth provider name get: summary: Start OAuth login flow operationId: oauthRedirect tags: [auth] description: | Redirects the user to the OAuth provider's authorization page. Sets a short-lived CSRF state cookie for validation on callback. responses: "302": description: Redirect to provider authorization URL "404": description: Provider not found or not configured content: application/json: schema: $ref: "#/components/schemas/Error" /v1/auth/oauth/{provider}/callback: parameters: - name: provider in: path required: true schema: type: string enum: [github] description: OAuth provider name get: summary: OAuth callback operationId: oauthCallback tags: [auth] description: | Handles the OAuth provider's callback after user authorization. Exchanges the authorization code for a user profile, creates or logs in the user, and redirects to the frontend with a JWT token. **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?token=...&user_id=...&team_id=...&email=...` **On error:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?error=...` Possible error codes: `access_denied`, `invalid_state`, `missing_code`, `exchange_failed`, `email_taken`, `internal_error`. parameters: - name: code in: query schema: type: string description: Authorization code from the OAuth provider - name: state in: query schema: type: string description: CSRF state parameter (must match the cookie) responses: "302": description: Redirect to frontend with token or error /v1/me: get: summary: Get current user profile operationId: getMe tags: [account] security: - bearerAuth: [] responses: "200": description: User profile content: application/json: schema: $ref: "#/components/schemas/MeResponse" patch: summary: Update display name operationId: updateName tags: [account] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string minLength: 1 maxLength: 100 responses: "200": description: Name updated, new JWT issued content: application/json: schema: $ref: "#/components/schemas/AuthResponse" "400": description: Invalid name content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Delete current account operationId: deleteAccount tags: [account] security: - bearerAuth: [] description: | Soft-deletes the account (sets status=deleted, deleted_at=now). The account is permanently removed after 15 days. Blocked if the user owns any team that has other members. requestBody: required: true content: application/json: schema: type: object required: [confirmation] properties: confirmation: type: string description: Must match the user's email address (case-insensitive) responses: "204": description: Account scheduled for deletion "400": description: Confirmation does not match email content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: User owns teams with other members content: application/json: schema: $ref: "#/components/schemas/Error" /v1/me/password: post: summary: Change or add password operationId: changePassword tags: [account] security: - bearerAuth: [] description: | For users with an existing password: requires `current_password` and `new_password`. For OAuth-only users adding a password: requires `new_password` and `confirm_password`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ChangePasswordRequest" responses: "204": description: Password updated "400": description: Invalid request (short password, mismatch, etc.) content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Current password is incorrect content: application/json: schema: $ref: "#/components/schemas/Error" /v1/me/password/reset: post: summary: Request a password reset email operationId: requestPasswordReset tags: [account] description: | Sends a password reset link to the given email. Always returns 200 regardless of whether the email exists, to prevent account enumeration. The reset token expires in 15 minutes. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "204": description: Request accepted (email sent if account exists) /v1/me/password/reset/confirm: post: summary: Confirm password reset operationId: confirmPasswordReset tags: [account] description: | Consumes a password reset token and sets a new password. The token is single-use and expires after 15 minutes. requestBody: required: true content: application/json: schema: type: object required: [token, new_password] properties: token: type: string description: Raw reset token from the email link new_password: type: string minLength: 8 responses: "204": description: Password reset successful "400": description: Invalid or expired token, or password too short content: application/json: schema: $ref: "#/components/schemas/Error" /v1/me/providers/{provider}/connect: parameters: - name: provider in: path required: true schema: type: string enum: [github] description: OAuth provider name get: summary: Initiate OAuth provider link operationId: connectProvider tags: [account] security: - bearerAuth: [] description: | Sets OAuth state and link cookies, then returns the provider's authorization URL. The frontend navigates to this URL to start the OAuth flow. On callback, the provider is linked to the current account (not a new registration). responses: "200": description: Authorization URL content: application/json: schema: type: object properties: auth_url: type: string format: uri "404": description: Provider not found or not configured content: application/json: schema: $ref: "#/components/schemas/Error" /v1/me/providers/{provider}: parameters: - name: provider in: path required: true schema: type: string enum: [github] description: OAuth provider name delete: summary: Disconnect an OAuth provider operationId: disconnectProvider tags: [account] security: - bearerAuth: [] description: | Unlinks the OAuth provider from the current account. Blocked if this is the user's only login method (no password and no other providers). responses: "204": description: Provider disconnected "400": description: Cannot disconnect last login method content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Provider not connected content: application/json: schema: $ref: "#/components/schemas/Error" /v1/api-keys: post: summary: Create an API key operationId: createAPIKey tags: [api-keys] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateAPIKeyRequest" responses: "201": description: API key created (plaintext key only shown once) content: application/json: schema: $ref: "#/components/schemas/APIKeyResponse" "401": description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/Error" get: summary: List API keys for your team operationId: listAPIKeys tags: [api-keys] security: - bearerAuth: [] responses: "200": description: List of API keys (plaintext keys are never returned) content: application/json: schema: type: array items: $ref: "#/components/schemas/APIKeyResponse" /v1/api-keys/{id}: parameters: - name: id in: path required: true schema: type: string delete: summary: Delete an API key operationId: deleteAPIKey tags: [api-keys] security: - bearerAuth: [] responses: "204": description: API key deleted /v1/users/search: get: summary: Search users by email prefix operationId: searchUsers tags: [users] security: - bearerAuth: [] description: | Returns up to 10 users whose email starts with the given prefix. The prefix must contain "@". Intended for the add-member UI autocomplete. parameters: - name: email in: query required: true schema: type: string description: Email prefix (must contain "@", e.g. "alice@") responses: "200": description: Matching users content: application/json: schema: type: array items: $ref: "#/components/schemas/UserSearchResult" "400": description: Prefix does not contain "@" content: application/json: schema: $ref: "#/components/schemas/Error" /v1/teams: get: summary: List teams for the authenticated user operationId: listTeams tags: [teams] security: - bearerAuth: [] responses: "200": description: Teams the user belongs to, each with their role content: application/json: schema: type: array items: $ref: "#/components/schemas/TeamWithRole" post: summary: Create a new team operationId: createTeam tags: [teams] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string description: 1-128 chars; A-Z a-z 0-9 space _ responses: "201": description: Team created (caller is owner) content: application/json: schema: $ref: "#/components/schemas/TeamWithRole" "400": description: Invalid team name content: application/json: schema: $ref: "#/components/schemas/Error" /v1/teams/{id}: parameters: - name: id in: path required: true schema: type: string description: Team ID (must match the JWT's team_id) get: summary: Get team info and member list operationId: getTeam tags: [teams] security: - bearerAuth: [] responses: "200": description: Team details with members content: application/json: schema: $ref: "#/components/schemas/TeamDetail" "403": description: JWT team does not match requested team content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Team not found content: application/json: schema: $ref: "#/components/schemas/Error" patch: summary: Rename the team operationId: renameTeam tags: [teams] security: - bearerAuth: [] description: Admin or owner role required (verified from DB). requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string responses: "204": description: Renamed "400": description: Invalid team name content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: Insufficient role content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Delete the team operationId: deleteTeam tags: [teams] security: - bearerAuth: [] description: | Owner only. Soft-deletes the team and destroys all running/paused/starting capsulees. All DB records are preserved. The team slug is permanently reserved. responses: "204": description: Team deleted "403": description: Caller is not the owner content: application/json: schema: $ref: "#/components/schemas/Error" /v1/teams/{id}/members: parameters: - name: id in: path required: true schema: type: string get: summary: List team members operationId: listTeamMembers tags: [teams] security: - bearerAuth: [] responses: "200": description: Members with roles content: application/json: schema: type: array items: $ref: "#/components/schemas/TeamMember" post: summary: Add a member by email operationId: addTeamMember tags: [teams] security: - bearerAuth: [] description: Admin or owner role required. User is added instantly as a member. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: type: string format: email responses: "201": description: Member added content: application/json: schema: $ref: "#/components/schemas/TeamMember" "403": description: Insufficient role content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: No account with that email content: application/json: schema: $ref: "#/components/schemas/Error" "400": description: User is already a member content: application/json: schema: $ref: "#/components/schemas/Error" /v1/teams/{id}/members/{uid}: parameters: - name: id in: path required: true schema: type: string - name: uid in: path required: true schema: type: string description: Target user ID patch: summary: Update member role operationId: updateMemberRole tags: [teams] security: - bearerAuth: [] description: | Admin or owner required. Valid target roles: admin, member. The owner's role cannot be changed. requestBody: required: true content: application/json: schema: type: object required: [role] properties: role: type: string enum: [admin, member] responses: "204": description: Role updated "403": description: Insufficient role or attempt to modify owner content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: User is not a member content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Remove a member operationId: removeTeamMember tags: [teams] security: - bearerAuth: [] description: Admin or owner required. Owner cannot be removed. responses: "204": description: Member removed "403": description: Insufficient role or attempt to remove owner content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: User is not a member content: application/json: schema: $ref: "#/components/schemas/Error" /v1/teams/{id}/leave: parameters: - name: id in: path required: true schema: type: string post: summary: Leave the team operationId: leaveTeam tags: [teams] security: - bearerAuth: [] description: The owner cannot leave; they must delete the team instead. responses: "204": description: Left the team "403": description: Owner cannot leave content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules: post: summary: Create a capsule operationId: createCapsule tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCapsuleRequest" responses: "201": description: Capsule created content: application/json: schema: $ref: "#/components/schemas/Capsule" "502": description: Host agent error content: application/json: schema: $ref: "#/components/schemas/Error" get: summary: List capsulees for your team operationId: listCapsules tags: [capsules] security: - apiKeyAuth: [] responses: "200": description: List of capsulees content: application/json: schema: type: array items: $ref: "#/components/schemas/Capsule" /v1/capsules/stats: get: summary: Get capsule usage stats for your team operationId: getCapsuleStats tags: [capsules] security: - apiKeyAuth: [] parameters: - name: range in: query required: false schema: type: string enum: [5m, 1h, 6h, 24h, 30d] default: 1h description: Time window for the time-series data. responses: "200": description: Capsule stats for the team content: application/json: schema: $ref: "#/components/schemas/CapsuleStats" "400": $ref: "#/components/responses/BadRequest" /v1/capsules/{id}: parameters: - name: id in: path required: true schema: type: string get: summary: Get capsule details operationId: getCapsule tags: [capsules] security: - apiKeyAuth: [] responses: "200": description: Capsule details content: application/json: schema: $ref: "#/components/schemas/Capsule" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Destroy a capsule operationId: destroyCapsule tags: [capsules] security: - apiKeyAuth: [] responses: "204": description: Capsule destroyed /v1/capsules/{id}/exec: parameters: - name: id in: path required: true schema: type: string post: summary: Execute a command operationId: execCommand tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ExecRequest" responses: "200": description: Command output (foreground exec) content: application/json: schema: $ref: "#/components/schemas/ExecResponse" "202": description: Background process started content: application/json: schema: $ref: "#/components/schemas/BackgroundExecResponse" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/processes: parameters: - name: id in: path required: true schema: type: string get: summary: List running processes operationId: listProcesses tags: [capsules] security: - apiKeyAuth: [] description: | Returns all running processes inside the capsule, including background processes and any processes started by templates or init scripts. responses: "200": description: Process list content: application/json: schema: $ref: "#/components/schemas/ProcessListResponse" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/processes/{selector}: parameters: - name: id in: path required: true schema: type: string - name: selector in: path required: true description: Process PID (numeric) or tag (string) schema: type: string delete: summary: Kill a process operationId: killProcess tags: [capsules] security: - apiKeyAuth: [] parameters: - name: signal in: query required: false description: Signal to send (SIGKILL or SIGTERM, default SIGKILL) schema: type: string enum: [SIGKILL, SIGTERM] default: SIGKILL responses: "204": description: Process killed "404": description: Capsule or process not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/processes/{selector}/stream: parameters: - name: id in: path required: true schema: type: string - name: selector in: path required: true description: Process PID (numeric) or tag (string) schema: type: string get: summary: Stream process output via WebSocket operationId: connectProcess tags: [capsules] security: - apiKeyAuth: [] description: | Opens a WebSocket connection to stream stdout/stderr from a running background process. The selector can be a numeric PID or a string tag. Server sends JSON messages: - `{"type": "start", "pid": 42}` — connected to process - `{"type": "stdout", "data": "..."}` — stdout output - `{"type": "stderr", "data": "..."}` — stderr output - `{"type": "exit", "exit_code": 0}` — process exited - `{"type": "error", "data": "..."}` — error message responses: "101": description: WebSocket upgrade /v1/capsules/{id}/ping: parameters: - name: id in: path required: true schema: type: string post: summary: Reset capsule inactivity timer operationId: pingCapsule tags: [capsules] security: - apiKeyAuth: [] description: | Resets the last_active_at timestamp for a running capsule, preventing the auto-pause TTL from expiring. Use this as a keepalive for capsulees that are idle but should remain running. responses: "204": description: Ping acknowledged, inactivity timer reset "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/metrics: parameters: - name: id in: path required: true schema: type: string get: summary: Get per-capsule resource metrics operationId: getCapsuleMetrics tags: [capsules] security: - apiKeyAuth: [] - bearerAuth: [] description: | Returns time-series CPU, memory, and disk metrics for a capsule. Three tiers are available with different granularity and retention: - `10m`: 500ms samples, last 10 minutes - `2h`: 30-second averages, last 2 hours - `24h`: 5-minute averages, last 24 hours For running capsulees, data comes from the host agent's in-memory ring buffer. For paused capsulees, data is read from persisted snapshots in the database. Stopped/destroyed capsulees return 404. parameters: - name: range in: query required: false schema: type: string enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] default: "10m" description: Time range filter to query responses: "200": description: Metrics retrieved content: application/json: schema: $ref: "#/components/schemas/CapsuleMetrics" "400": description: Invalid range parameter content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Capsule not found or metrics not available content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/pause: parameters: - name: id in: path required: true schema: type: string post: summary: Pause a running capsule operationId: pauseCapsule tags: [capsules] security: - apiKeyAuth: [] description: | Takes a snapshot of the capsule (VM state + memory + rootfs), then destroys all running resources. The capsule exists only as files on disk and can be resumed later. responses: "200": description: Capsule paused (snapshot taken, resources released) content: application/json: schema: $ref: "#/components/schemas/Capsule" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/resume: parameters: - name: id in: path required: true schema: type: string post: summary: Resume a paused capsule operationId: resumeCapsule tags: [capsules] security: - apiKeyAuth: [] description: | Restores a paused capsule from its snapshot using UFFD for lazy memory loading. Boots a fresh Firecracker process, sets up a new network slot, and waits for envd to become ready. responses: "200": description: Capsule resumed (new VM booted from snapshot) content: application/json: schema: $ref: "#/components/schemas/Capsule" "409": description: Capsule not paused content: application/json: schema: $ref: "#/components/schemas/Error" /v1/snapshots: post: summary: Create a snapshot template operationId: createSnapshot tags: [snapshots] security: - apiKeyAuth: [] description: | Pauses a running capsule, takes a full snapshot, copies the snapshot files to the images directory as a reusable template, then destroys the capsule. The template can be used to create new capsulees. parameters: - name: overwrite in: query required: false schema: type: string enum: ["true"] description: Set to "true" to overwrite an existing snapshot with the same name. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateSnapshotRequest" responses: "201": description: Snapshot created content: application/json: schema: $ref: "#/components/schemas/Template" "409": description: Name already exists or capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" get: summary: List templates for your team operationId: listSnapshots tags: [snapshots] security: - apiKeyAuth: [] parameters: - name: type in: query required: false schema: type: string enum: [base, snapshot] description: Filter by template type. responses: "200": description: List of templates content: application/json: schema: type: array items: $ref: "#/components/schemas/Template" /v1/snapshots/{name}: parameters: - name: name in: path required: true schema: type: string delete: summary: Delete a snapshot template operationId: deleteSnapshot tags: [snapshots] security: - apiKeyAuth: [] description: Removes the snapshot files from disk and deletes the database record. responses: "204": description: Snapshot deleted "404": description: Template not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/write: parameters: - name: id in: path required: true schema: type: string post: summary: Upload a file operationId: uploadFile tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: multipart/form-data: schema: type: object required: [path, file] properties: path: type: string description: Absolute destination path inside the capsule file: type: string format: binary description: File content responses: "204": description: File uploaded "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" "413": description: File too large content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/read: parameters: - name: id in: path required: true schema: type: string post: summary: Download a file operationId: downloadFile tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReadFileRequest" responses: "200": description: File content content: application/octet-stream: schema: type: string format: binary "404": description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/list: parameters: - name: id in: path required: true schema: type: string post: summary: List directory contents operationId: listDir tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ListDirRequest" responses: "200": description: Directory listing content: application/json: schema: $ref: "#/components/schemas/ListDirResponse" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/mkdir: parameters: - name: id in: path required: true schema: type: string post: summary: Create a directory operationId: makeDir tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MakeDirRequest" responses: "200": description: Directory created content: application/json: schema: $ref: "#/components/schemas/MakeDirResponse" "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/remove: parameters: - name: id in: path required: true schema: type: string post: summary: Remove a file or directory operationId: removePath tags: [capsules] security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RemoveRequest" responses: "204": description: File or directory removed "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/exec/stream: parameters: - name: id in: path required: true schema: type: string get: summary: Stream command execution via WebSocket operationId: execStream tags: [capsules] security: - apiKeyAuth: [] description: | Opens a WebSocket connection for streaming command execution. **Client sends** (first message to start the process): ```json {"type": "start", "cmd": "tail", "args": ["-f", "/var/log/syslog"]} ``` **Client sends** (to stop the process): ```json {"type": "stop"} ``` **Server sends** (process events as they arrive): ```json {"type": "start", "pid": 1234} {"type": "stdout", "data": "line of output\n"} {"type": "stderr", "data": "warning message\n"} {"type": "exit", "exit_code": 0} {"type": "error", "data": "description of error"} ``` The connection closes automatically after the process exits. responses: "101": description: WebSocket upgrade "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/pty: parameters: - name: id in: path required: true schema: type: string get: summary: Interactive PTY session via WebSocket operationId: ptySession tags: [capsules] security: - apiKeyAuth: [] description: | Opens a WebSocket connection for an interactive PTY (terminal) session. Supports creating new sessions, sending input, resizing, killing, and reconnecting to existing sessions. **Client sends** (first message — start a new PTY): ```json { "type": "start", "cmd": "/bin/bash", "args": [], "cols": 80, "rows": 24, "envs": {"TERM": "xterm-256color"}, "cwd": "/home/user", "user": "user" } ``` All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24. **Client sends** (first message — reconnect to existing PTY): ```json {"type": "connect", "tag": "pty-abc123de"} ``` **Client sends** (after session is established): ```json {"type": "input", "data": ""} {"type": "resize", "cols": 120, "rows": 40} {"type": "kill"} ``` **Server sends**: ```json {"type": "started", "tag": "pty-abc123de", "pid": 42} {"type": "output", "data": ""} {"type": "exit", "exit_code": 0} {"type": "error", "data": "description", "fatal": true} {"type": "ping"} ``` PTY data (input and output) is base64-encoded because it contains raw terminal bytes (escape sequences, control codes) that are not valid UTF-8. Sessions persist across WebSocket disconnections — the process keeps running in the capsule. Use the `tag` from the "started" response to reconnect later. responses: "101": description: WebSocket upgrade "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/stream/write: parameters: - name: id in: path required: true schema: type: string post: summary: Upload a file (streaming) operationId: streamUploadFile tags: [capsules] security: - apiKeyAuth: [] description: | Streams file content to the capsule without buffering in memory. Suitable for large files. Uses the same multipart/form-data format as the non-streaming upload endpoint. requestBody: required: true content: multipart/form-data: schema: type: object required: [path, file] properties: path: type: string description: Absolute destination path inside the capsule file: type: string format: binary description: File content responses: "204": description: File uploaded "404": description: Capsule not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/capsules/{id}/files/stream/read: parameters: - name: id in: path required: true schema: type: string post: summary: Download a file (streaming) operationId: streamDownloadFile tags: [capsules] security: - apiKeyAuth: [] description: | Streams file content from the capsule without buffering in memory. Suitable for large files. Returns raw bytes with chunked transfer encoding. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReadFileRequest" responses: "200": description: File content streamed in chunks content: application/octet-stream: schema: type: string format: binary "404": description: Capsule or file not found content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Capsule not running content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts: post: summary: Create a host operationId: createHost tags: [hosts] security: - bearerAuth: [] description: | Creates a new host record and returns a one-time registration token. Regular hosts can only be created by admins. BYOC hosts can be created by admins or team owners. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateHostRequest" responses: "201": description: Host created with registration token content: application/json: schema: $ref: "#/components/schemas/CreateHostResponse" "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/Error" get: summary: List hosts operationId: listHosts tags: [hosts] security: - bearerAuth: [] description: | Admins see all hosts. Non-admins see only BYOC hosts belonging to their team. responses: "200": description: List of hosts content: application/json: schema: type: array items: $ref: "#/components/schemas/Host" /v1/hosts/{id}: parameters: - name: id in: path required: true schema: type: string get: summary: Get host details operationId: getHost tags: [hosts] security: - bearerAuth: [] responses: "200": description: Host details content: application/json: schema: $ref: "#/components/schemas/Host" "404": description: Host not found content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Delete a host operationId: deleteHost tags: [hosts] security: - bearerAuth: [] description: | Admins can delete any host. Team owners and admins can delete BYOC hosts belonging to their team. Without `?force=true`, returns 409 if the host has active capsulees. With `?force=true`, destroys all capsulees first. parameters: - name: force in: query required: false schema: type: boolean description: If true, destroy all capsulees on the host before deleting. responses: "204": description: Host deleted "403": description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Host has active capsulees (only when force is not set) content: application/json: schema: $ref: "#/components/schemas/HostHasCapsulesError" /v1/hosts/{id}/token: parameters: - name: id in: path required: true schema: type: string post: summary: Regenerate registration token operationId: regenerateHostToken tags: [hosts] security: - bearerAuth: [] description: | Issues a new registration token for a host still in "pending" status. Use this when a previous registration attempt failed after consuming the original token. Same permission model as host creation. responses: "201": description: New registration token issued content: application/json: schema: $ref: "#/components/schemas/CreateHostResponse" "403": description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Host is not in pending status content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/register: post: summary: Register a host agent operationId: registerHost tags: [hosts] description: | Called by the host agent on first startup. Validates the one-time registration token, records machine specs, sets the host status to "online", and returns a long-lived JWT for subsequent API calls (heartbeats). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RegisterHostRequest" responses: "201": description: Host registered, JWT returned content: application/json: schema: $ref: "#/components/schemas/RegisterHostResponse" "400": description: Invalid request content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: Invalid or expired registration token content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/{id}/heartbeat: parameters: - name: id in: path required: true schema: type: string post: summary: Host agent heartbeat operationId: hostHeartbeat tags: [hosts] security: - hostTokenAuth: [] description: | Updates the host's last_heartbeat_at timestamp. The host ID in the URL must match the host ID in the JWT. responses: "204": description: Heartbeat recorded "401": description: Invalid or missing host token content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: Host ID mismatch content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/auth/refresh: post: summary: Refresh host JWT operationId: refreshHostToken tags: [hosts] description: | Exchanges a refresh token for a new JWT and rotated refresh token. The old refresh token is immediately revoked. No authentication required — the refresh token itself is the credential. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RefreshHostTokenRequest" responses: "200": description: New JWT and rotated refresh token content: application/json: schema: $ref: "#/components/schemas/RefreshHostTokenResponse" "401": description: Invalid, expired, or revoked refresh token content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/{id}/delete-preview: parameters: - name: id in: path required: true schema: type: string get: summary: Preview host deletion operationId: getHostDeletePreview tags: [hosts] security: - bearerAuth: [] description: | Returns the list of capsule IDs that would be destroyed if the host were deleted with `?force=true`. No state is modified. responses: "200": description: Deletion preview content: application/json: schema: $ref: "#/components/schemas/HostDeletePreview" "403": description: Insufficient permissions content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Host not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/{id}/tags: parameters: - name: id in: path required: true schema: type: string get: summary: List host tags operationId: listHostTags tags: [hosts] security: - bearerAuth: [] responses: "200": description: List of tags content: application/json: schema: type: array items: type: string post: summary: Add a tag to a host operationId: addHostTag tags: [hosts] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AddTagRequest" responses: "204": description: Tag added "404": description: Host not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/hosts/{id}/tags/{tag}: parameters: - name: id in: path required: true schema: type: string - name: tag in: path required: true schema: type: string delete: summary: Remove a tag from a host operationId: removeHostTag tags: [hosts] security: - bearerAuth: [] responses: "204": description: Tag removed "404": description: Host not found content: application/json: schema: $ref: "#/components/schemas/Error" /v1/channels: post: summary: Create a notification channel operationId: createChannel tags: [channels] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateChannelRequest" responses: "201": description: Channel created content: application/json: schema: $ref: "#/components/schemas/ChannelResponse" "400": $ref: "#/components/responses/BadRequest" get: summary: List notification channels operationId: listChannels tags: [channels] security: - bearerAuth: [] responses: "200": description: Channels list content: application/json: schema: type: array items: $ref: "#/components/schemas/ChannelResponse" /v1/channels/test: post: summary: Test a channel configuration description: > Sends a test notification using the provided provider and config without saving anything. Use this to verify credentials before creating a channel. operationId: testChannel tags: [channels] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TestChannelRequest" responses: "200": description: Test notification sent successfully content: application/json: schema: type: object properties: status: type: string example: ok "400": $ref: "#/components/responses/BadRequest" /v1/channels/{id}: parameters: - name: id in: path required: true schema: type: string get: summary: Get a notification channel operationId: getChannel tags: [channels] security: - bearerAuth: [] responses: "200": description: Channel details content: application/json: schema: $ref: "#/components/schemas/ChannelResponse" "404": description: Channel not found content: application/json: schema: $ref: "#/components/schemas/Error" patch: summary: Update a notification channel operationId: updateChannel tags: [channels] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateChannelRequest" responses: "200": description: Channel updated content: application/json: schema: $ref: "#/components/schemas/ChannelResponse" "400": $ref: "#/components/responses/BadRequest" "404": description: Channel not found content: application/json: schema: $ref: "#/components/schemas/Error" delete: summary: Delete a notification channel operationId: deleteChannel tags: [channels] security: - bearerAuth: [] responses: "204": description: Channel deleted /v1/channels/{id}/config: parameters: - name: id in: path required: true schema: type: string put: summary: Rotate channel secrets description: > Replaces the channel's provider configuration entirely with new secrets. The previous config is discarded. Config fields must match the provider's required fields. operationId: rotateChannelConfig tags: [channels] security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RotateConfigRequest" responses: "200": description: Config rotated content: application/json: schema: $ref: "#/components/schemas/ChannelResponse" "400": $ref: "#/components/responses/BadRequest" "404": description: Channel not found content: application/json: schema: $ref: "#/components/schemas/Error" components: securitySchemes: apiKeyAuth: type: apiKey in: header name: X-API-Key description: API key for capsule lifecycle operations. Create via POST /v1/api-keys. bearerAuth: type: http scheme: bearer bearerFormat: JWT description: JWT token from /v1/auth/login or /v1/auth/signup. Valid for 6 hours. hostTokenAuth: type: apiKey in: header name: X-Host-Token description: Host JWT returned from POST /v1/hosts/register or POST /v1/hosts/auth/refresh. Valid for 7 days. schemas: SignupRequest: type: object required: [email, password, name] properties: email: type: string format: email password: type: string minLength: 8 name: type: string maxLength: 100 LoginRequest: type: object required: [email, password] properties: email: type: string format: email password: type: string SignupResponse: type: object properties: message: type: string description: Confirmation message instructing user to check email AuthResponse: type: object properties: token: type: string description: JWT token (valid for 6 hours) user_id: type: string team_id: type: string email: type: string name: type: string CreateAPIKeyRequest: type: object properties: name: type: string default: Unnamed API Key APIKeyResponse: type: object properties: id: type: string team_id: type: string name: type: string key_prefix: type: string description: Display prefix (e.g. "wrn_ab12cd34...") created_at: type: string format: date-time last_used: type: string format: date-time nullable: true key: type: string description: Full plaintext key. Only returned on creation, never again. nullable: true CreateCapsuleRequest: type: object properties: template: type: string default: minimal vcpus: type: integer default: 1 memory_mb: type: integer default: 512 timeout_sec: type: integer default: 0 description: > Auto-pause TTL in seconds. The capsule is automatically paused after this duration of inactivity (no exec or ping). 0 means no auto-pause. CapsuleStats: type: object properties: range: type: string enum: [5m, 1h, 6h, 24h, 30d] current: type: object properties: running_count: type: integer vcpus_reserved: type: integer memory_mb_reserved: type: integer sampled_at: type: string format: date-time nullable: true peaks: type: object description: Maximum values over the last 30 days. properties: running_count: type: integer vcpus: type: integer memory_mb: type: integer series: type: object description: Parallel arrays for chart rendering. properties: labels: type: array items: type: string format: date-time running: type: array items: type: integer vcpus: type: array items: type: integer memory_mb: type: array items: type: integer Capsule: type: object properties: id: type: string status: type: string enum: [pending, starting, running, paused, hibernated, stopped, missing, error] template: type: string vcpus: type: integer memory_mb: type: integer timeout_sec: type: integer guest_ip: type: string host_ip: type: string created_at: type: string format: date-time started_at: type: string format: date-time nullable: true last_active_at: type: string format: date-time nullable: true last_updated: type: string format: date-time CreateSnapshotRequest: type: object required: [sandbox_id] properties: sandbox_id: type: string description: ID of the running capsule to snapshot. name: type: string description: Name for the snapshot template. Auto-generated if omitted. Template: type: object properties: name: type: string type: type: string enum: [base, snapshot] vcpus: type: integer nullable: true memory_mb: type: integer nullable: true size_bytes: type: integer format: int64 created_at: type: string format: date-time ExecRequest: type: object required: [cmd] properties: cmd: type: string args: type: array items: type: string timeout_sec: type: integer default: 30 description: Timeout in seconds (foreground exec only, default 30) background: type: boolean default: false description: If true, starts the process in the background and returns immediately with a PID and tag (HTTP 202) tag: type: string description: Optional user-chosen tag for the background process. Auto-generated if omitted. Only used when background is true. envs: type: object additionalProperties: type: string description: Environment variables for the process (background exec only) cwd: type: string description: Working directory for the process (background exec only) BackgroundExecResponse: type: object properties: sandbox_id: type: string cmd: type: string pid: type: integer tag: type: string ProcessEntry: type: object properties: pid: type: integer tag: type: string cmd: type: string args: type: array items: type: string ProcessListResponse: type: object properties: processes: type: array items: $ref: "#/components/schemas/ProcessEntry" ExecResponse: type: object properties: sandbox_id: type: string cmd: type: string stdout: type: string stderr: type: string exit_code: type: integer duration_ms: type: integer encoding: type: string enum: [utf-8, base64] description: Output encoding. "base64" when stdout/stderr contain binary data. ReadFileRequest: type: object required: [path] properties: path: type: string description: Absolute file path inside the capsule ListDirRequest: type: object required: [path] properties: path: type: string description: Directory path inside the capsule depth: type: integer default: 1 description: Recursion depth (0 = non-recursive, 1 = immediate children) ListDirResponse: type: object properties: entries: type: array items: $ref: "#/components/schemas/FileEntry" FileEntry: type: object properties: name: type: string path: type: string type: type: string enum: [file, directory, symlink] size: type: integer format: int64 mode: type: integer permissions: type: string description: Human-readable permissions (e.g. "-rwxr-xr-x") owner: type: string group: type: string modified_at: type: integer format: int64 description: Unix timestamp (seconds) symlink_target: type: string nullable: true MakeDirRequest: type: object required: [path] properties: path: type: string description: Directory path to create inside the capsule MakeDirResponse: type: object properties: entry: $ref: "#/components/schemas/FileEntry" RemoveRequest: type: object required: [path] properties: path: type: string description: Path to remove inside the capsule CreateHostRequest: type: object required: [type] properties: type: type: string enum: [regular, byoc] description: Host type. Regular hosts are shared; BYOC hosts belong to a team. team_id: type: string description: Required for BYOC hosts. provider: type: string description: Cloud provider (e.g. aws, gcp, hetzner, bare-metal). availability_zone: type: string description: Availability zone (e.g. us-east, eu-west). CreateHostResponse: type: object properties: host: $ref: "#/components/schemas/Host" registration_token: type: string description: One-time registration token for the host agent. Expires in 1 hour. RegisterHostRequest: type: object required: [token, address] properties: token: type: string description: One-time registration token from POST /v1/hosts. arch: type: string description: CPU architecture (e.g. x86_64, aarch64). cpu_cores: type: integer memory_mb: type: integer disk_gb: type: integer address: type: string description: Host agent address (ip:port). RegisterHostResponse: type: object properties: host: $ref: "#/components/schemas/Host" token: type: string description: Host JWT for X-Host-Token header. Valid for 7 days. refresh_token: type: string description: Refresh token for obtaining new JWTs. Valid for 60 days; rotated on each use. Host: type: object properties: id: type: string type: type: string enum: [regular, byoc] team_id: type: string nullable: true provider: type: string nullable: true availability_zone: type: string nullable: true arch: type: string nullable: true cpu_cores: type: integer nullable: true memory_mb: type: integer nullable: true disk_gb: type: integer nullable: true address: type: string nullable: true status: type: string enum: [pending, online, offline, draining, unreachable] last_heartbeat_at: type: string format: date-time nullable: true created_by: type: string created_at: type: string format: date-time updated_at: type: string format: date-time RefreshHostTokenRequest: type: object required: [refresh_token] properties: refresh_token: type: string description: Refresh token obtained from registration or a previous refresh. RefreshHostTokenResponse: type: object properties: host: $ref: "#/components/schemas/Host" token: type: string description: New host JWT. Valid for 7 days. refresh_token: type: string description: New refresh token. Valid for 60 days; old token is revoked. HostDeletePreview: type: object properties: host: $ref: "#/components/schemas/Host" sandbox_ids: type: array items: type: string description: IDs of capsulees that would be destroyed on force-delete. HostHasCapsulesError: type: object properties: error: type: object properties: code: type: string example: host_has_sandboxes message: type: string sandbox_ids: type: array items: type: string description: IDs of active capsulees blocking deletion. AddTagRequest: type: object required: [tag] properties: tag: type: string UserSearchResult: type: object properties: user_id: type: string email: type: string Team: type: object properties: id: type: string name: type: string slug: type: string description: Immutable 12-char hex slug (e.g. a1b2c3-d1e2f3) created_at: type: string format: date-time TeamWithRole: allOf: - $ref: "#/components/schemas/Team" - type: object properties: role: type: string enum: [owner, admin, member] TeamMember: type: object properties: user_id: type: string email: type: string role: type: string enum: [owner, admin, member] joined_at: type: string format: date-time TeamDetail: type: object properties: team: $ref: "#/components/schemas/Team" members: type: array items: $ref: "#/components/schemas/TeamMember" CapsuleMetrics: type: object properties: sandbox_id: type: string range: type: string enum: ["5m", "10m", "1h", "2h", "6h", "12h", "24h"] points: type: array items: $ref: "#/components/schemas/MetricPoint" MetricPoint: type: object properties: timestamp_unix: type: integer format: int64 cpu_pct: type: number format: double description: "CPU utilization percentage (0-100), normalized to vCPU count" mem_bytes: type: integer format: int64 description: "Resident memory in bytes (VmRSS of Firecracker process)" disk_bytes: type: integer format: int64 description: "Allocated disk bytes for the CoW sparse file" CreateChannelRequest: type: object required: [name, provider, config, events] properties: name: type: string description: Unique channel name within the team. provider: type: string enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] config: type: object additionalProperties: type: string description: > Provider-specific configuration fields. Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}. Telegram: {"bot_token": "...", "chat_id": "..."}. Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}. Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted). events: type: array items: type: string enum: - capsule.created - capsule.running - capsule.paused - capsule.destroyed - template.snapshot.created - template.snapshot.deleted - host.up - host.down TestChannelRequest: type: object required: [provider, config] properties: provider: type: string enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] config: type: object additionalProperties: type: string description: Provider-specific configuration fields (same as CreateChannelRequest.config). RotateConfigRequest: type: object required: [config] properties: config: type: object additionalProperties: type: string description: > New provider configuration fields. Must include all required fields for the channel's provider. Replaces the existing config entirely. UpdateChannelRequest: type: object required: [name, events] properties: name: type: string events: type: array items: type: string enum: - capsule.created - capsule.running - capsule.paused - capsule.destroyed - template.snapshot.created - template.snapshot.deleted - host.up - host.down ChannelResponse: type: object properties: id: type: string team_id: type: string name: type: string provider: type: string enum: [discord, slack, teams, googlechat, telegram, matrix, webhook] events: type: array items: type: string created_at: type: string format: date-time updated_at: type: string format: date-time secret: type: string nullable: true description: Webhook secret. Only returned on creation, never again. MeResponse: type: object properties: name: type: string email: type: string format: email has_password: type: boolean description: Whether the user has a password set (false for OAuth-only accounts) providers: type: array items: type: string description: List of linked OAuth provider names (e.g. ["github"]) ChangePasswordRequest: type: object required: [new_password] properties: current_password: type: string description: Required when changing an existing password new_password: type: string minLength: 8 confirm_password: type: string description: Required when adding a password to an OAuth-only account (must match new_password) Error: type: object properties: error: type: object properties: code: type: string message: type: string