A standalone, multi-tenant identity and authentication gateway for AI agents. Overslash handles everything between "an agent wants to call an external API" and "the API call runs with the right credentials."
Overslash is purely an auth and identity layer. It does not orchestrate agents, manage compute, track which nodes are connected, schedule work, or know anything about the runtime environment agents live in. It answers one question: "is this identity allowed to do this action with these credentials?" — and if yes, calls the authenticated HTTP request.
It owns: identity hierarchy, secret management, OAuth flows, permission rules, human approval workflows, action calls, service registry, and audit trail.
The name: it slashes through doors and auth for the user.
AI agents that interact with external services (GitHub, Gmail, Stripe, Slack, etc.) face a common set of problems that every agent platform rebuilds from scratch:
- Secret management — agents need API keys and tokens, but shouldn't hold them in context
- OAuth flows — connecting to services requires redirect flows, token storage, and refresh logic
- Permission gating — destructive actions (sending emails, creating PRs, charging cards) need human approval
- Audit trail — organizations need to know what their agents did, when, and with whose authority
- Identity hierarchy — agents spawn sub-agents, which spawn more sub-agents. Who approved what?
Every agent platform (Overfolder, OpenClaw, custom harnesses) solves these independently, badly. The auth code is coupled to the agent loop. Permissions are prompt-based ("please ask before sending"). Secrets leak into conversation context.
Overslash extracts all of this into a single service with a clean REST API that any agent platform can call.
- Standalone service — not embedded in any agent framework
- Multi-tenant — organizations with isolated identities, secrets, and audit
- Hierarchical identities — users own agents, agents spawn sub-agents, permissions flow up
- Versioned secret vault — encrypted, never returned via API, with version history
- OAuth engine — system credentials and BYOC (Bring Your Own Client) per identity (User or Agent)
- Permission chains — every level in the identity hierarchy must authorize an action
- Human approval workflows — with expiry, "Allow & Remember" with TTL, approval URLs for any channel
- Universal HTTP execution — any REST API, with or without a service definition
- Service registry — YAML-defined services (global + org-extensible) with human-readable action descriptions
- Audit everything — every action, approval, secret access, connection change
- Three integration surfaces over one backend — REST API, CLI (
overslash), and MCP server, so any HTTP client, shell-capable agent, or MCP-aware editor can use Overslash without rebuilding the same plumbing - Meta tools — minimal tool interface for LLM agents (
overslash_search,overslash_call,overslash_auth,overslash_approve) available across REST, CLI, and MCP surfaces - Web UI — for org admins and users to manage everything visually, served by Vercel in cloud mode and embedded same-origin in self-hosted mode (
overslash web) - Single-binary self-hosting —
overslashships everything (API, dashboard, MCP server) so an org can run the entire product from one executable
- Being an agent framework or LLM router — Overslash doesn't know about LLMs, prompts, or agent loops
- Orchestrating agents — Overslash does not schedule, dispatch, or coordinate agent work. It has no concept of tasks, queues, or workflows.
- Managing compute or infrastructure — no awareness of nodes, containers, runtimes, or where agents run. Overslash doesn't know or care what machine an agent lives on.
- Tracking agent connectivity — Overslash does not monitor which agents are online, healthy, or reachable. It authenticates requests when they arrive.
- Executing code or managing VMs — Overslash calls HTTP requests, not arbitrary programs
- Channel-specific UIs (Telegram bots, WhatsApp) — callers build their own; Overslash provides approval URLs
- Being a general-purpose API gateway — no rate limiting of upstream APIs, no caching, no transformation
| Component | Tech | Purpose |
|---|---|---|
| Backend | Rust / Axum | REST API, OAuth engine, permission resolver, action executor, audit logger |
| Web UI | SvelteKit | Web UI for org admins and users |
| PostgreSQL | — | All persistent state |
| Encryption | AES-256-GCM | Secret storage (key via env var or KMS) |
| Valkey (optional) | Redis-compatible | Webhook delivery queue, approval notification pub/sub, rate-limit counters. Valkey is preferred over Redis (see DECISIONS.md D4); Redis remains drop-in compatible. |
Any Caller (agent platform, CI, human, script)
│
│ Authorization: Bearer ovs_acme_agent-henry_...
│
▼
┌─────────────────────────────────┐
│ Overslash │
│ │
│ Identity → Permission Chain │
│ Secret Vault → Auth Injection │
│ OAuth Engine → Token Refresh │
│ Service Registry → Action Build │
│ Approval Workflow → Gate/Allow │
│ Audit Trail → Log Everything │
└──────────────┬──────────────────┘
│
▼
External Service
(GitHub, Google, Stripe, ...)
Overslash exposes three peer surfaces over the same backend. The REST API is canonical; the CLI and MCP server are thin wrappers that hold credentials locally and call the REST API on the user's or agent's behalf.
| Surface | Audience | Transport | Credentials held | Distribution |
|---|---|---|---|---|
| REST API | Platforms, CI, custom integrations, agents capable of HTTP | HTTPS / JSON | Caller's choice (per-request Authorization: Bearer ...) |
Hosted at the org's Overslash domain |
CLI (overslash) |
Developers, shell-capable agents, ops/admin scripting | Local process invocation, REST under the hood | One identity at a time, from ~/.config/overslash/ or env |
Single static binary |
| MCP server | LLM agents inside MCP-aware editors (Claude Code, Cursor, Windsurf, ...) | Primary: MCP Streamable HTTP at POST /mcp (same Axum process as the REST API) with OAuth 2.1 (browser flow). Compat: stdio shim overslash mcp for editors whose MCP transport is stdio-only — proxies frames to POST /mcp. |
None — the MCP client (or stdio shim) holds them. Default: an OAuth-issued user access token. Advanced: a static osk_… agent API key. |
The HTTP transport ships in overslash serve / overslash web. The optional stdio shim is a subcommand of the same binary (overslash mcp). |
The MCP server reuses Overslash's existing IdP login flow as an OAuth 2.1 Authorization Server. A standards-compliant MCP client (Claude Code, Cursor, Windsurf, …) discovers the AS via WWW-Authenticate on a 401 from /mcp, fetches /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource, dynamically registers itself (RFC 7591), opens a browser for the consent step, and uses the resulting access token on every subsequent JSON-RPC call. Editors that only speak stdio MCP point at overslash mcp (the compat shim), which holds a single token in ~/.config/overslash/mcp.json and proxies every JSON-RPC frame to POST /mcp over Bearer. The shim is a pipe — no business logic, no dual credentials.
The session established by OAuth is an agent identity owned by the signed-in user, not the user itself: /oauth/authorize pauses at an in-app consent step where the user creates or picks the agent the MCP client will act as, and the (user, client_id) → agent binding is stored so repeat logins skip the prompt. Layer 2 applies (see §4 MCP OAuth Enrollment). Static osk_… agent keys remain available for non-interactive callers (CI, headless deployments). Approvals surface via the standard webhook / SSE / approval-URL path (§10 Async Event Delivery). Full design in docs/design/mcp-oauth-transport.md. White-label platforms (e.g., Overfolder) bypass the MCP server entirely and call the REST API directly with their own UX.
Overslash ships as a single executable, overslash, with subcommands:
| Command | Purpose |
|---|---|
overslash help |
Standard clap-generated help, including per-subcommand --help |
overslash serve |
Start the REST API only. Cloud-mode default. The dashboard is served separately (Vercel + SSR) and proxies API traffic back. |
overslash web |
Start the REST API and serve the SvelteKit dashboard same-origin from the same Axum process. Self-hosted mode. The dashboard is built with @sveltejs/adapter-static and embedded into the binary at compile time, so a single binary is the entire product. |
overslash mcp |
Stdio-to-HTTP shim for MCP clients that don't yet speak Streamable HTTP. Reads ~/.config/overslash/mcp.json (server URL + bearer token) and proxies every JSON-RPC frame to POST <server>/mcp. No business logic — the actual MCP server lives in overslash serve / overslash web. See §10 for tool details. |
overslash mcp login |
Mint a token for the stdio shim by running the standard OAuth Authorization Code + PKCE flow against the configured server (opens a browser, captures the callback on 127.0.0.1, writes ~/.config/overslash/mcp.json). Replaces the old paste-tokens helper. |
MCP clients that speak Streamable HTTP (Claude Code, Cursor, Windsurf, …) point at the server URL directly and handle OAuth themselves via the AS endpoints:
{
"mcpServers": {
"overslash": {
"type": "http",
"url": "https://<your-overslash>/mcp"
}
}
}On first connection the client hits POST /mcp, receives 401 + WWW-Authenticate, follows the AS metadata, dynamically registers, opens a browser for the consent step, and proceeds — no pre-shared secrets or prior CLI step required. See docs/design/mcp-oauth-transport.md.
Editors that only speak stdio MCP use the overslash mcp compat shim instead:
{
"mcpServers": {
"overslash": {
"command": "overslash",
"args": ["mcp"]
}
}
}The stdio shim requires overslash mcp login once (runs the same OAuth flow interactively) and then proxies every JSON-RPC frame to POST /mcp. Both shapes terminate at the same handler and produce the same (user → agent) → MCP token binding server-side.
serve and web share the same create_app router and config — web only adds a static-file fallback and same-origin defaults. The mcp shim carries no Postgres or Axum dependency; it is a tiny stdio↔HTTP pipe.
- Cloud serves orgs off a single wildcard origin
app.overslash.com.*.app.overslash.comresolves to the same instance; subdomain middleware maps theHostheader to anorg_id.app.overslash.com(the root) hosts Overslash-level login and the/accountpage;<slug>.app.overslash.comhosts an individual corp org. - Self-hosted runs the same binary and code path. Two env flags scope it down:
ALLOW_ORG_CREATION=false— disablesPOST /v1/orgsand the dashboard's "Create org" CTAs. Existing orgs keep working.SINGLE_ORG_MODE=<slug>— disables subdomain middleware; every request is scoped to the named org, the root-domain login lands directly in that org with no personal-org auto-creation, and the org switcher is hidden.
Self-hosted operators who want the "old" single-org experience set SINGLE_ORG_MODE=<their-org-slug>. Self-hosted operators who want full multi-org (e.g., an internal PaaS) leave both flags unset. See docs/design/multi_org_auth.md.
Users authenticate to Overslash via external Identity Providers (IdPs). Overslash is a Relying Party (RP) — it does not store passwords or manage user credentials directly.
Protocol: OpenID Connect (OIDC) — the authentication layer built on OAuth 2.0. OIDC provides identity (who the user is) via ID tokens, while OAuth alone only handles authorization. Overslash uses the Authorization Code Flow with PKCE for all web-based logins.
Supported IdP types:
- Social providers — Google, GitHub (pre-configured, just needs client ID/secret)
- Corporate SSO — any OIDC-compliant IdP (Okta, Azure AD, Auth0, Keycloak, etc.) configured via the IdP's issuer URL. Overslash uses OpenID Connect Discovery (
.well-known/openid-configuration) to auto-discover endpoints — org-admins only need to provide the issuer URL, client ID, and client secret. - SAML 2.0 — supported for enterprise environments that require it (many corporate IdPs only offer SAML). Overslash acts as a SAML Service Provider (SP). However, OIDC is preferred where both are available — SAML is XML-heavy, harder to debug, and less suited to SPAs.
- Dev login — a debug-only login method (enabled via env var, disabled in production) for local development without an external IdP.
Configuration sources: IdPs can be configured via environment variables or in-database settings. Env vars take precedence — an IdP set via env var cannot be disabled or modified from the dashboard (shown as read-only with an "env" badge). This includes dev login: if DEV_LOGIN=true is set, it's active regardless of DB settings. In-database IdPs are managed by org-admins in the Org Dashboard settings.
IdP credential resolution. Each IdP config stores its own client_id and client_secret. However, when org-level OAuth App Credentials exist for the same provider (§7), the IdP config defaults to those — the [+ Add Provider] flow pre-populates the fields from OAUTH_{PROVIDER}_CLIENT_ID / SECRET org secrets. The org-admin can accept the defaults (sharing one OAuth app for both login and API access) or override with dedicated credentials (e.g., a separate GCP project for login with a narrower consent screen). IdP configs that use the org defaults stay in sync — updating the org OAuth App Credential updates the IdP automatically. IdP configs with overrides are independent.
Per-org IdP configuration: Each org configures its own IdPs. An org can enable multiple IdPs simultaneously (e.g., Google for convenience + corporate Okta for SSO).
User provisioning. Overslash separates "the human" (users table) from "the actor in an org" (existing identities table linked via user_id). A users row is either Overslash-backed (bound to a root-level IdP in overslash_idp_provider/overslash_idp_subject, owns a personal org) or org-only (bound only through identities in corp orgs). Lookups at login are always by (provider, subject); email is informational and is never used to merge users across IdPs or grant memberships.
How humans end up in orgs.
- Personal org — auto-created the first time a human signs in at the root domain via an Overslash-level IdP. Exactly one member, always the owner. Personal orgs cannot configure per-org IdPs and have no subdomain.
- Corp org, as creator — an Overslash-backed user creates a corp org via
POST /v1/orgs; they receive a regularadminmembership + an adminidentitiesrow in the new org. An org may stay on the Overslash-level IdP indefinitely (creator is its sole admin) or later configure its own IdP to onboard more humans. The creator's Overslash-level login continues to reach the org in either case — they're just an admin, no special flag. - Corp org, as member — sign in through any IdP the org has enabled on
<slug>.app.overslash.com(per-orgorg_idp_configsrow, or Overslash's shared OAuth app when the org opts in). Two admission paths, picked by the org admin:- Domain-whitelist path (default for pre-2026-05 orgs): auto-provisioning is gated by
org_idp_configs.allowed_email_domains(empty list = trust the IdP entirely). - Managed sign-in path (
orgs.allow_overslash_managed_signin = true, default for orgs created after migration 066): authentication via Overslash's shared OAuth apps is decoupled from membership — the IdP proves who you are, and a second gate proves you belong. Migration 092 makes that gate configurable viaorgs.require_invite_admission(defaulttrue):- Invite-required (
require_invite_admission = true, the default): every new member must already exist as a pre-created user identity for that email (an invite, or an impersonation-provisioned member) — regardless of which IdP authenticates them. First sign-in adopts that identity by email. Membership crossing trust domains is blocked unless the admin explicitly invites the email. - Domain-allowlist (
require_invite_admission = false): any verified email whose domain is on the org-wideorgs.managed_signin_allowed_domainslist self-provisions asmemberon first login, no invite needed. An empty allowlist here is a misconfiguration, not "admit everyone" — sign-in is rejected. This is distinct from the per-providerorg_idp_configs.allowed_email_domainsused by the domain-whitelist path above; the managed list is org-wide because the managed path admits through multiple env-var providers sharing one trust boundary (the operator's env creds).
- Invite-required (
- Domain-whitelist path (default for pre-2026-05 orgs): auto-provisioning is gated by
No cross-IdP account linking. A human who uses Google for personal and Okta for Acme has two distinct users rows. This is intentional: Google and Okta are different trust domains and the system treats them as such. See docs/design/multi_org_auth.md.
Org (acme)
└── User (alice) depth=0
└── Agent (henry) depth=1, parent=alice
├── SubAgent (researcher) depth=2, parent=henry
└── SubAgent (emailer) depth=2, parent=henry
- Users auto-provisioned on first IdP login — at the root domain for personal orgs, at the org subdomain for corp org members, or via
POST /v1/orgsfor corp org creators (who become regular admins) - Agents created by users
- Sub-agents created by agents — no user intervention needed
- UI equivalence: the UI does not distinguish between Agents and Sub-agents — they are all presented as "Agents" in the tree. The
sub_agentkind remains an API/backend distinction (for idle cleanup and depth tracking), but the UI treats them identically. The only difference visible to users is who the parent is. - Each identity has API keys for authenticating with Overslash
- Sub-agents are garbage-collected by idle timeout (ephemeral workers): if a sub-agent has not made an authenticated request for longer than the org's
subagent_idle_timeout_secs, it is archived in two phases — first its API keys are auto-revoked and pending approvals expired (archived_atset), then aftersubagent_archive_retention_daysthe row is hard-deleted. Archived identities return403 identity_archivedfrom the gateway with arestorable_untiltimestamp, andPOST /v1/identities/{id}/restoreun-archives within the retention window and resurrects the auto-revoked API keys (manually-revoked keys stay revoked). Parents never archive while a live sub-agent child exists, so active subtrees outlive idle parents. Org admins configure both knobs in[4h, 60d]and[1d, 60d]ranges respectively. Users and agents are never auto-archived.
Enrollment is MCP OAuth 2.1 (MCP spec 2025-06-18 — RFC 8414 + RFC 7591 + PKCE). There is one path: an MCP client (Claude Code, Cursor, Windsurf, an overslash mcp login CLI run for editors that only take a static Bearer header, …) connects to /mcp, discovers the Authorization Server at /.well-known/oauth-authorization-server, registers itself via POST /oauth/register, and drives the user through a browser-hosted Authorization Code + PKCE flow at /oauth/authorize. A short instruction page for agents lives at /SKILL.md (served by the API, see the repo-root SKILL.md).
Consent. After the user signs in at /oauth/authorize, the server pauses the authorize request and redirects the browser to the dashboard at /oauth/consent?request_id=…. The dashboard renders the enrollment card (design-system styled) and calls a small JSON API (GET /v1/oauth/consent/{request_id}, POST /v1/oauth/consent/{request_id}/finish). In new mode the user picks a parent (defaults to themselves), toggles inherit_permissions (off by default — users opt in rather than out), and optionally attaches the agent to groups (search-and-create, with everyone implicit and never shown). In reauth mode — recognised when a DCR re-registration produces a new client_id but the previously-enrolled client_name + software_id still match an unrevoked binding for the same user — the card skips the form and simply rebinds the new client_id to the existing agent, preserving that agent's rules and groups.
Binding. On submission, the server persists a (user_identity_id, client_id) → agent_identity_id row and the dashboard follows the returned redirect_uri back to the MCP client with an auth code bound to the agent. Subsequent authorizations from the same (user, client_id) reuse the binding and skip the prompt. The issued access token's sub is the agent; /mcp refuses any token whose sub points at a user-kind identity so a pre-binding or CSRF-stolen token can't slip through. The consent screen is hosted inline in the OAuth flow — there is no separate "consent URL" sent out-of-band.
Headless / long-lived credentials. Static osk_… API keys minted via POST /v1/api-keys remain the credential for non-interactive callers (CI, batch jobs) — see §Authentication. Device-flow OAuth for headless clients is a future add.
After enrollment, an identity's configuration remains mutable:
- Parent: an identity can be reparented to a different position in the hierarchy (within the user's subtree)
inherit_permissions: can be enabled or disabled at any time- Remembered approvals: can be viewed and revoked per identity
A live pointer (not a copy). When set on an identity, it dynamically has whatever permissions its parent has — current AND future. Parent gains a rule tomorrow, child gains it too.
An API key that carries the impersonate scope (admin-minted only) may execute any request as another identity in its own org. The target is named by the X-Overslash-As header, which accepts three forms:
| Header value | Resolves to |
|---|---|
<uuid> |
an existing identity, by id — never created |
alice@acme.com |
the user with that email — created on demand if the org has never seen it |
alice@acme.com/henry/researcher |
that user, then a slash-separated path of agent names beneath her — each missing level created on demand (henry as an agent, researcher as a sub_agent, …) |
The value splits on /: the first segment is the user (a UUID or an email — neither can contain /), and each remaining segment is an agent name, resolved then created one level at a time. The effective identity is the last segment; all downstream permission checks, approvals, and audit run as it. A UUID first segment may also carry a path (<uuid>/henry) provided it names a user.
Auto-created identities are unprivileged by construction: user identities are bare org members (external_id IS NULL — "belongs to the org, has never signed in"), and agents never inherit permissions. Every auto-creation writes an identity.provisioned audit row attributing it to the impersonating key. The existing ACL cap still applies to the final effective identity — an impersonation key can never reach an identity whose access exceeds the key's own.
A path deeper than 8 agent segments, a malformed root (neither UUID nor email), or an empty segment is a 400; an archived target is 403; the header without the impersonate scope is 403. This is the primary integration surface for white-label backends (e.g. Overfolder) that act on their users' behalf without pre-syncing Overslash UUIDs.
A person "belongs to an org" iff there is a kind='user' identity for them in that org. There is exactly one such identity per person per org, and it is created by any of three paths — an admin invite, name-based impersonation, or a first SSO sign-in — that all converge on the same row:
- A pending invite is a user identity with
external_id IS NULL(invited, never signed in); an admin invite additionally carriesis_org_admin+ Admins-group membership. - First sign-in adopts by email: the OAuth callback finds the pre-created identity by verified email and stamps the IdP subject onto it, rather than forking a second identity — so the person lands on their pre-created agents, connections, and audit history.
orgs.require_invite_admissionkeeps its meaning: with it on, a verified email with no pre-created identity is rejectednot_invited. - A second IdP for the same email is admitted by email onto that same identity; one human = one identity per org.
external_idstays pinned to the subject that originally claimed the identity — it is never rewritten by a later provider, so it cannot flip-flop as a member alternates IdPs (which would make the(org_id, external_id)fast path and cross-org subject matching depend on whichever login happened last).
Impersonation-provisioning is an admission decision. A pre-created identity satisfies require_invite_admission regardless of which admin-authorized path created it — an explicit invite or name-based impersonation. This is not a bypass: the impersonate scope is admin-minted, and the provisioned row is already a full member (Everyone + Myself groups) the moment it is created, before any login. Refusing to adopt it at sign-in would not unmake the member; it would only stop the real human from ever logging into an account that is already theirs. Because adoption is the moment a pre-created identity becomes a human-usable login, it emits an identity.adopted audit row carrying provisioned_by (invite vs impersonation), so an org can tell the two admission paths apart after the fact.
(The former org_invites table was folded into identities by migration 103; the /v1/org-invites API keeps its wire shape as a projection over user identities.)
All permissions in Overslash use a single key format:
{service}:{action}:{arg}
This format covers every level of abstraction — from registry-defined actions to raw HTTP:
| Key | Meaning |
|---|---|
github:create_pull_request:overfolder/* |
Registry action, scoped to repos |
github:*:* |
Any action on GitHub |
github:POST:/repos/*/pulls |
Specific HTTP verb + path against GitHub |
github:ANY:* |
Any HTTP request against GitHub |
http:POST:api.example.com |
Raw HTTP to a specific host |
http:ANY:* |
Unrestricted HTTP proxy |
secret:gh_token:api.github.com |
Inject a specific secret toward a specific host |
email:send:recipient=jane@example.com |
Registry action, scoped to one labelled value |
email:send:recipient=*@example.com |
…the same label, any address in a domain |
Labelled args. {arg} is either a bare value or {label}={value}. The
label comes from the action's scope_param (see §9) and names what kind of
thing the value is, which is what lets several params share one grant: email
scopes to, cc, and bcc all under recipient, so one key covers an address
wherever it appears — and a bcc to an outsider is gated exactly like a to. An
action scoping a single param labels it with the param name
(github:get_repo:repo=acme/api).
A value-only pattern matches a labelled key on any label; a
label=-qualified pattern matches only that label. Every rule written before
labels existed therefore keeps working, and a narrower rule is available when
the header actually matters:
| Rule | to=a@example.com |
cc=a@example.com |
|---|---|---|
email:send:*@example.com (value-only) |
✅ | ✅ |
email:send:cc=*@example.com |
❌ | ✅ |
email:send:* |
✅ | ✅ |
The equivalence is one-way. A label=-qualified pattern does not cover a
label-less key: nothing in email:send:a@example.com says which header carried
that address, so a cc=-scoped grant cannot be honoured over it without
granting more than the rule states. This is only observable for permission keys
persisted on an approval before the action gained a label — live calls always
derive the current shape.
A prefix counts as a label only when everything before the first = is a
bare identifier ([A-Za-z_][A-Za-z0-9_]*); otherwise the whole arg is the
value, so http:GET:api.example.com/x?a=1 is never sliced. The parse is purely
lexical — an unlabelled arg that happens to read word=rest is therefore
treated as labelled, and gains the value-only match form …:rest. Derived args
are *, a URL/host, or a label=-prefixed scope value, none of which hit that
case; it is a consideration only for hand-written rules.
Special action values:
- HTTP verbs (
GET,POST,PUT,DELETE, etc.) — allow specific HTTP methods against the service ANY— allow any HTTP method*— wildcard matching any action (note:{service}:*:*currently permits both registry actions and raw HTTP verbs against the service; in the future, groups may introduce finer-grained controls to limit{service}:ANYor direct HTTP access even when{service}:*:*is granted)
Pseudo-services:
http— raw HTTP access with no service abstraction. The arg is the target host. Most orgs won't grant this — it turns Overslash into a general HTTP proxy.secret— secret injection gating. The action is the secret name, the arg is the target host. Required alongsidehttpkeys when secrets are injected. Prevents a secret approved for one host from being exfiltrated to another.
Permissions are enforced in two layers:
Layer 1: Groups (coarse-grained ceiling, org-admin managed)
Groups define which services are available and at what access level. They constrain users, and agents inherit their owner-user's group ceiling. A request that exceeds the group ceiling is denied outright — no approval can override it. Groups also control service visibility: if a user isn't in any group granting access to a service, that service is hidden from service listings and the API Explorer.
Group grants reference org-level service instances directly (via FK), paired with a structured access level:
Group examples:
- "Engineering": github (write), slack (write), stripe (read)
- "Admin": github (admin), slack (admin), stripe (admin), + raw HTTP access
- "Read-only": github (read), slack (read)
Access levels map to the Risk enum:
- read — non-mutating actions only (
risk: read, or GET/HEAD/OPTIONS for raw HTTP) - write — read + mutating actions (
risk: write, + POST/PUT/PATCH) - admin — full access including destructive actions (
risk: delete, + DELETE)
Raw HTTP access goes through the system-managed http service instance (one per org, created at bootstrap). Group access is granted via the standard group_grants mechanism, with the same access-level → risk mapping as any other service: read covers GET/HEAD/OPTIONS, write adds POST/PUT/PATCH, admin adds DELETE. There is no allow_raw_http boolean — raw HTTP is just another service from the ceiling's point of view, and most orgs leave it un-granted on Everyone.
Myself groups. Every user identity has an automatically-managed "Myself" group (system_kind = 'self', exactly one member: the user). When a user creates a service — or an agent creates one on_behalf_of its owner-user, which is the default for any identity-bound service create — the service is owned by the user and auto-granted to that user's Myself group with access_level = 'admin' and auto_approve_level = 'read'. The owner can downgrade these grants (cap at read, drop auto-approval to none), raise auto-approval up to the ceiling, or fully remove them; ownership lives on service_instances.owner_identity_id independently of the grant, so a removed grant can be re-added by the owner from the dashboard at any time.
Myself group constraints. Myself groups carry tighter invariants than user-created groups, enforced at the API:
- Membership is fixed. The owner-user is the only member; backend rejects add/remove on
system_kind = 'self'. - Grants are owner-scoped.
POST /v1/groups/{id}/grantsrejects anyservice_instance_idwhoseowner_identity_iddoes not match the group'sowner_identity_id. A user manages their own Myself, but can only attach services they themselves own — admins cannot smuggle another user's service into someone else's Myself. - System metadata is immutable. Rename, description edit, and delete are rejected for system groups (Everyone, Admins, and any Myself).
- Default listing visibility.
GET /v1/groupsreturns the caller's own Myself plus all non-self groups they can see; other users' Myself groups are filtered out so an admin's group list doesn't get one row per user.?include_self=true(admin opt-in) returns every Myself group in the org for cross-user audit. - Display name. The DB-stored name
Myself: <label> (<uuid8>)is an internal disambiguator for the(org_id, name)unique constraint (two users may share an email per migration 043). Clients render the group as "Myself" in the caller's own context. Admin audit views may disambiguate as "Myself (email)", falling back to "Myself (email, id8)" only on email collision. API consumers detect Myself viasystem_kind === 'self', never by parsing the name.
There is no separate "user-level service" tier in the permission model. owner_identity_id survives as a namespace marker (so alice's github shadows the org github in her own resolution — see §9 Services (Instances)) and as a provenance bit, but every permission decision flows through the same group_grants ceiling. Org admins can additionally grant any service — including ones owned by individual users — to other groups, making admin-driven sharing first-class.
There is no permissive default. After the Myself migration, every bootstrapped user identity belongs to at least the Everyone and Myself system groups, and Everyone always carries the overslash:write grant from org bootstrap, so ceiling.grants is never empty in practice and the ceiling is always enforced. The NoGroups permissive branch survives only as a safety net for org-level keys with no identity at all.
Auto-approve level (D53): Each service grant carries a second ceiling, auto_approve_level ∈ none | read | write | admin, on the same ladder as access_level and bounded by it. Where access_level answers "may this run at all?", auto_approve_level answers "may it run without a human?". When a matching grant's level permits the action's risk, Layer 2 is bypassed entirely: the call runs immediately, no permission rule is created, no approval is filed. Anything above the level goes through the normal approval flow.
none— every call files an approval (the default for a new grant).read— non-mutating calls (risk: read, or GET/HEAD/OPTIONS for raw HTTP) run unattended. This is what the retiredauto_approve_reads = trueboolean meant, and the default on the Myself grant, so an agent reading from one of its owner-user's own services skips approval without polluting the agent's permission-rule list.write— adds POST/PUT/PATCH-class actions. For read-heavy and write-heavy agent loops on a service where mutations are cheap and reversible (a scratch project, an internal channel).admin— addsrisk: delete.
Auto-approval can never exceed access_level: raising it past the ceiling is a 400, and lowering the ceiling clamps it down. It is permission to skip the human, never permission to exceed the grant. A deny rule still overrides it — every auto-approved mutating call runs the deny-only chain sweep before dispatch, so a carve-out an admin made on purpose survives a write-level grant.
auto_approve_reads remains accepted on the API for one release as a deprecated alias (true ⇒ "read") and is returned derived as auto_approve_level != "none".
Layer 2: Permission keys (fine-grained, user-managed, agent-specific)
Within the group ceiling, agents require specific permission keys for each action. Keys are created when a user clicks "Allow & Remember" on an approval — they are never written by hand. Permission keys build up organically as agents are used and users approve their actions. Users acting through the dashboard or API Explorer are gated by groups only — they are their own approvers.
Where grants come from for a given caller:
┌─────────────────────────────┐
│ ceiling_user_id (owner) │
│ user → self ; agent → owner │
└──────────────┬──────────────┘
│ identity_groups
┌───────────┼─────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌────────────┐ ┌─────────────────┐
│ Myself │ │ Everyone │ │ any number │
│ (system) │ │ (system) │ │ of admin- │
│ owner=me │ │ allow_raw │ │ created │
│ │ │ _http │ │ groups │
└─────┬─────┘ └─────┬──────┘ └────────┬────────┘
│ │ │
│ group_grants (access_level + auto_approve_level)
▼ ▼ ▼
┌───────────────────────────────────────┐
│ Union: CeilingGrant per service │
│ (most-permissive grant wins per svc) │
└───────────────────────────────────────┘
How a single action call is authorized end-to-end:
Action invoked
│
▼
Resolve ceiling_user_id (user → self ; agent → owner)
│
▼
load_ceiling(user) ──► union of grants from Myself + Everyone +
│ any admin-created groups the user is in
▼
check_ceiling(service, risk) → { result, read_bypass }
│
├── ExceedsCeiling ───────────► [DENY] (not approvable)
│
└── WithinCeiling
│
▼
identity.kind == user ?
│
┌───────┴────────┐
│ │
yes no (agent)
│ │
▼ ▼
[CALL now] auto_approved ?
(auto_approve_level permits this risk)
│
┌───────┴────────┐
│ │
yes no
│ │
▼ ▼
[CALL now] Layer 2: walk permission chain
(no rule (sub-agent → agent → user)
written) │
┌────────┴─────────┐
│ │
all keys present gap
│ │
▼ ▼
[CALL now] Create approval at gap level
(resolver = first ancestor
with the keys, else the user)
The win this delivers compared to the previous "user-owned service bypass": an agent reading from one of its owner-user's services no longer has to wait for a human approval on the first call. The Myself grant's auto_approve_level = 'read' short-circuits Layer 2 for reads — no popup, no permission-rule clutter — while writes still flow through approval until the owner raises the level.
The flow above expanded as discrete steps:
- Agent makes a request → system derives permission keys from the request
- Group check (Layer 1): is the service + access level within the owner-user's group grants? If not → deny (not approvable)
- Auto-approve bypass: if the matching grant's
auto_approve_levelpermits this action's risk, skip Layer 2 and call immediately (a deny rule still applies to mutating calls) - Permission key check (Layer 2): are all derived keys covered by existing rules for this identity? If yes → auto-approve
- If not → create approval request → user decides → "Allow & Remember" stores keys with optional TTL
When a sub-agent calls an action, every level in the ancestor chain must authorize:
- Check sub-agent → has matching key or
inherit_permissions? Pass, continue up. - Check agent → has matching key? Pass, continue up.
- Check user → within group ceiling? Pass. All levels authorized → call.
- First level without a matching key and without
inherit_permissions→ gap. Create an approval (see below).
When a gap is found, an approval is created. The approval is always linked to the requesting identity (identity_id = the agent that triggered the action) — for audit, display, and so the requester sees the same approval whether resolved by an agent or a user.
The approval has a current resolver: the closest ancestor that can act on it. The resolver search walks upward from the requester:
- An ancestor can resolve only if the requested permission is within its own boundary (parent cannot grant a child more than itself has — same/narrower keys, same/shorter TTL).
- Identities with
inherit_permissions=trueare skipped — they don't own permissions, they borrow. - The user is always the final resolver of last resort (constrained only by the group ceiling).
The current resolver receives the approval (via webhook or polling) and chooses one of:
- Approve (Allow Once) — the approval transitions to
allowedand anexecutionsrow (status='pending', 15-minute lifetime) is created. By default (per-agentauto_call_on_approve = true, the universal post-resolve setting), the gateway spawns a background replay immediately after/resolvereturns — agents and white-label platforms don't have to round-trip a manual call. The execution still goes through the same atomic claim guard, so a manualPOST /v1/approvals/{id}/callfrom the requesting agent or the resolver's "Execute Now" button cleanly loses with a 409 if it lands during an in-flight auto-call. Org admins can fliporgs.default_deferred_execution = trueto seed new agents into "deferred execution" mode (existing agents are not touched), or per-agent owners can flipidentities.auto_call_on_approve = falseto require an explicit/callfor every approve. If neither auto-call nor a manual/callfinalizes within 15 minutes the pending execution expires and no action runs. The resolver may alsoPOST /v1/approvals/{id}/cancelto invalidate the pending execution; on Allow Once this is terminal for the agent — it must request a fresh approval to try again. - Approve & Remember — as above, and on successful
/calla permission rule is stored (see "Rule placement" below). Cancel, expire, or replay failure ⇒ no rule is persisted; the reviewer can retry after addressing the underlying cause. - Bubble up — defer to the next ancestor that can resolve, or the user if none.
- Reject — denied. No execution row is created; the stored
action_detailremains for audit.
Rule placement on Approve & Remember: the new permission rule is added to the closest non-inherit_permissions ancestor of the requester (inclusive of the requester). Identities with inherit_permissions=true are skipped because their permissions are dynamic — putting a rule there would be silently overridden by parent walks. This is the requester's "permission-owning" identity.
Cascade resolution. When the remembered rule lands, other pending approvals whose requester is the placement identity or a descendant are re-walked; the ones the new rule now structurally allows are auto-resolved (resolved_by='cascade') with a fresh pending execution each. Cascaded approvals follow the same post-resolve behavior as a direct approve: if the cascaded approval's own requesting agent has auto_call_on_approve = true, the gateway spawns the background replay immediately (same atomic claim guard and elicitation suppression as /resolve); otherwise the execution waits for a manual POST /v1/approvals/{id}/call. Cascade executions always carry remember=false, so a cascade-triggered replay can never store new rules or trigger further cascades.
Why this arrangement is expected to be common. Real-world agent deployments tend to converge on a multi-level hierarchy:
- A single powerful "main" agent per user. Users want one always-on agent (a Chief of Staff, an executive assistant, an ops manager) with broad authority across the services they own. It's the day-to-day driver and the entry point for delegation.
- Specialist sub-agents with minimum-privilege boundaries. The main agent spawns long-lived specialists (Marketing, Finance, Engineering, Support) that each own a slice of the business. These intentionally do not inherit the parent's full power — they get only the services they need. This keeps a compromised or misbehaving specialist from reaching beyond its lane.
- Ephemeral task-scoped sub-sub-agents. Specialists in turn spin up short-lived workers (a Researcher, a Reviewer, a Drafter) for individual tasks. These typically do use
inherit_permissions=truebecause they're temporary and their privileges should track the parent's exactly — there's no lasting identity to grant rules to anyway.
The result is a User → Main → Specialist → Worker shape with inherit_permissions=true only at the leaves. The bubbling model is designed for exactly this: most approvals get handled by the specialist or the main agent (which already has the relevant authority), and the user is only pulled in when something genuinely crosses a privilege boundary.
Example. Chain: User:alice → Agent:ChiefOfStaff → Agent:Marketing → Agent:Researcher (inherit_permissions=true).
- alice's group grants
service-a,service-b,service-c(full). - ChiefOfStaff has rules for
service-a:*andservice-b:*. - Marketing has rules for
service-a:*. - Researcher inherits from Marketing.
Researcher calls service-b:action:
- The approval is filed against Researcher (
identity_id = researcher). - Researcher is skipped (inherits). Marketing can't resolve (no
service-b). ChiefOfStaff hasservice-b→ initial resolver = ChiefOfStaff. - Chief picks Approve & Remember → rule is created on Marketing (Researcher's closest non-inherit ancestor), not on Researcher.
- Next time Researcher does the same action, it auto-passes via Marketing's new rule.
If Chief instead bubbles up → resolver = User. If Researcher had called service-c:action, the resolver search would have skipped Marketing (no service-c) and ChiefOfStaff (no service-c) and gone directly to alice.
Auto-bubble timeout. If an approval sits with its current resolver longer than approval_auto_bubble_secs (per-org setting, default 300s = 5 minutes), it automatically bubbles to the next ancestor. Setting this to 0 makes every approval go straight to the user (skip agent resolvers entirely). This prevents requests from getting stuck on an absent or unresponsive agent resolver.
GET /v1/approvals accepts an optional ?scope= filter so callers can ask three different questions about the same pending-approvals set without round-tripping the whole list:
?scope=mine— approvals the caller has requested (identity_id = caller). Useful for an agent polling "what am I waiting on?" or for a user to see things they themselves submitted via the dashboard.?scope=assigned— approvals where the caller is the current resolver right now (current_resolver_identity_id = caller). This is the strict "inbox" view: only approvals that are sitting on this exact identity, not on a descendant. Excludes anything the caller requested themselves (the self-resolve ban — see "Trust Model and Approval Resolution" below — would block resolution anyway).?scope=actionable— approvals the caller could act on: the caller is the current resolver, or any descendant of the caller is the current resolver. An ancestor can always step in for a descendant, so this surfaces everything in the caller's subtree. Also excludes self-requested approvals.- No
scope— legacy org-wide listing of all pending approvals. Preserved for back-compat with admin tooling.
mine, assigned, and actionable all require an identity-bound credential (the caller has to be a real identity to ask "is this mine?").
The three scopes layer naturally for a dashboard inbox: assigned is the bell-badge count, actionable is the broader queue an org admin or main agent can drain on behalf of subordinates, and mine is the "outbox" of things the caller is waiting on.
The core trust assumption: agents are not trusted to approve their own actions. Overslash exists precisely because prompt-based permission ("please ask before sending") is not real security. The approval system enforces this:
Who can resolve an approval:
- Users — via the Overslash dashboard (logged in) or via the platform's UX calling the resolve API with the user's credentials
- Ancestor agents — an agent can approve for its sub-agents, but only if the permission being granted is already within the agent's own boundary (same or narrower keys, same or shorter TTL). A parent cannot grant a child more than it has itself.
- The requesting agent itself — never. An agent cannot resolve its own approval requests.
How approvals flow through the platform:
- Agent calls
overslash_callvia the platform → gets{ "status": "pending_approval", "approval_id": "apr_abc123" }. - The agent cannot resolve this. The platform receives the approval event (via webhook or polling on the user's behalf).
- The platform surfaces the approval to the user in its own UX (Telegram buttons, Slack message, CLI prompt, etc.) including the
suggested_tiersanddescriptionfrom the approval payload. - The user makes a decision. The platform calls
POST /v1/approvals/{id}/resolveusing the user's Overslash credentials — not the agent's API key. Resolve does not run the action; onallow/allow_rememberit moves the approval toallowedand creates a pendingexecutionsrow. - Replay is then triggered explicitly by one of:
- Agent —
POST /v1/approvals/{id}/call(sync; returns the replayed result). - User — "Call Now" in the dashboard, which calls the same endpoint.
An atomic
pending → executingtransition plus a unique index on(approval_id)guarantees at-most-one replay even under user+agent races. If the original call asked forexecution: "async"(D62/D66), triggering it instead queues the replay for the async worker:/callanswers 202 with the sameApprovalResponsebody, carryingexecution_mode: "async"and apendingexecution markedqueued, and the result is fetched fromGET /v1/approvals/{id}/executionorGET /v1/executions/{id}. The synchronous claim carriesAND request IS NULL, so a queued row can never also be dialled inline.
- Agent —
- Pending executions expire after 15 minutes. The resolver may also
POST /v1/approvals/{id}/call.
The agent observes the outcome by polling GET /v1/approvals/{id} (the nested execution object transitions with the row) or by listening for the approval.executed / approval.execution_failed / approval.execution_cancelled webhooks. A dedicated GET /v1/approvals/{id}/execution endpoint returns the execution summary directly. For auto-fired executions (triggered_by="auto") and for any replay that ran on the async worker, the approval.executed webhook payload also carries the full action result so a white-label platform can render the outcome from a single delivery without a follow-up GET. A manual synchronous /call omits result because that caller already received it inline on the response; a manual call on an execution: "async" approval does not, since its 202 carried no body.
There is no self-authenticating approval URL. Approval resolution always requires credentials of an identity with authority over the requesting identity. This prevents an agent from obtaining and resolving its own approval link.
Overslash-hosted approval page: Overslash provides a deep-link URL for each approval: https://acme.overslash.dev/approvals/apr_abc123. This page requires login — if the logged-in user has authority to resolve the approval, they see the full approval details and specificity picker. If not logged in, they hit the login page and get redirected back. Platforms can include this URL when surfacing approvals to users as a zero-integration-effort path — the platform doesn't need to build its own approval UI. The platform decides whether to link to Overslash's page or handle resolution in its own UX.
(The secret request page at /secrets/provide/req_...?token=jwt uses a signed URL because providing a secret doesn't grant the agent authority — the agent still needs a separate approval to use it.)
Each agent identity can have at most 3 pending approvals at any time. When a new approval request is created and 3 already exist, the oldest pending request is automatically dropped (denied with reason "superseded"). This prevents stale approvals from accumulating when agents are actively working.
Approval and secret requests are not notified immediately. Only requests that remain unresolved for more than 1 minute trigger notifications (bell badge, email, webhook). This prevents flash notifications for requests that agents or ancestor identities resolve quickly on their own. Notifications auto-dismiss when the underlying request is resolved.
"Allow & Remember" on an approval creates permission key rules with optional TTL. These rules auto-approve matching future requests. Permission rules and remembered approvals are the same concept — "permission rules" is the storage format, "remembered approvals" is the user-facing term. Users can view and revoke them per identity via the dashboard.
The rule is stored only after a successful POST /v1/approvals/{id}/call — a cancelled, expired, or failed replay leaves no rule behind. This prevents a reviewer from being silently committed to auto-approving an action they never saw succeed.
Approval and action execution are decoupled into two stages. POST /v1/approvals/{id}/resolve records a decision (and, on allow/allow_remember, creates a pending executions row with a 15-minute lifetime); the action itself only runs when something explicitly calls POST /v1/approvals/{id}/call.
- Stored payload. At approval creation, Overslash serialises the resolved
ActionRequestplus the original caller'sfilterandprefer_streamflags intoapprovals.action_detail. Secret values are never stored — onlySecretRef(name + injection metadata), resolved fresh at replay time. A rotated secret is used in its current form. - At-most-once.
executions.approval_idis uniquely indexed and thepending → executingtransition is an atomic SQL UPDATE guarded bystatus='pending' AND expires_at > now(). User and agent can race/execute; exactly one wins, the other receives 409. Any terminal state (executed / failed / cancelled / expired) is sticky. - Identity & audit. Replay always uses the requester's identity for audit and rate limiting, regardless of whether the agent or the resolver pressed the button. The
audit_logsrow foraction.executedcarriesdetail.replayed_from_approvalanddetail.execution_id; a separateapproval.executedentry records the button press. - Streaming. Originally-streaming requests are replayed as buffered requests (bounded by
MAX_RESPONSE_BODY_BYTES) — there is no agent connection to stream to. The stored result flagsstreamed_originally: trueso callers can tell. - Deferred delivery.
deliver: "url"(D51) is not carried onto the approval payload, and the permission gate runs before the mint — so a gated deferred call returnspending_approvalwithout minting a token, and its replay executes buffered like any other. In practice this is rare: the actions that want deferred delivery arerisk: readdownloads. A gated one that returns a large binary will hit the buffered cap on replay; the fix, when something needs it, is for replay to re-mint a token rather than buffer. - Timeouts & orphans. The
/callhandler bounds the upstream call withEXECUTION_REPLAY_TIMEOUT_SECS(default 30). If the API crashes whilestatus='executing', a sweeper transitions the row tofailedwitherror='orphaned'after the timeout plus a minute of slack. - Ceilings. The group-ceiling check is not re-run at
/call— the resolver's allow is authoritative, and the ceiling was enforced at approval creation.
Permission keys (Layer 2) are an agent-only concept. When a request is authenticated as a user identity — not an agent — only Layer 1 (group ceiling) applies. There is no approval flow, no permission key resolution, no "Allow & Remember" prompt: the user is their own approver, and any action within their group ceiling is called immediately.
This rule is transport-agnostic. It holds for the dashboard, the API Explorer, an MCP session logged in as a user, a CLI calling the REST API directly with user credentials, or any other surface. What matters is the identity type on the credential, not the channel.
A practical consequence: an MCP session established via the default OAuth flow is a user session, not an agent session. If a customer wants MCP usage gated by per-action approvals, they configure the MCP client (or the overslash mcp stdio shim) to authenticate with an osk_… agent API key directly, bypassing OAuth — at which point Layer 2 kicks in. In that mode, approvals surface via the standard webhook / SSE / approval-URL path (§10 Async Event Delivery); they are not resolved in-band by the same session that triggered them, because an agent cannot approve its own request.
When a platform (Overfolder, OpenClaw, etc.) is mediating between Overslash and a user — surfacing approvals, secret requests, and OAuth handoffs in its own UX — Overslash's built-in notification machinery (bell badge, email, 1-minute delayed webhook) becomes redundant and can produce duplicate prompts.
Each identity (or each org) can set notifications.managed_by_platform = true. When set:
- The 1-minute delayed notification webhook is suppressed for that identity's approvals and secret requests
- The bell badge and email notifications are suppressed
- The platform is responsible for surfacing pending events via its own webhook subscription, polling, or SSE stream (§10 Async event delivery)
This is a per-identity flag (so a single org can have both platform-mediated agents and direct-use agents) but typically set at agent-creation time by the platform's enrollment flow.
A scope_param normally takes the caller's argument verbatim. When that argument is an address rather than an identity — the same WhatsApp contact answers to both a phone JID and a privacy @lid — each spelling mints its own key, so a grant made against one silently misses the other and the rules list fills with opaque handles.
A param whose resolve: declares scope: (§9) fixes this: the resolver's canonical value replaces the raw argument when deriving permission keys only. recipient=239135323373760@lid and recipient=34600111222@s.whatsapp.net both become recipient=+34600111222.
Three properties make this safe to rely on:
- The outgoing request is untouched. Canonicalization renames the permission; it never retargets the call. The literal argument is what goes on the wire and what the approval discloses.
- It fails safe. When resolution fails there is no canonical value and the raw argument stands. That derives a different key, which matches no existing grant, so the call raises an approval rather than slipping through one.
- Trust is already spent. The canonical value comes from the same upstream the call is about to act on, which can already do anything the credential permits.
Note this is a live behaviour change for any grant already stored against a raw address: it stops matching once its template declares scope:, and re-prompts for approval once.
When an approval is created, Overslash derives the most specific permission keys from the request and generates broader alternatives by progressively replacing segments with *. These are returned as structured data in the approval payload — no human-readable labels, so platforms can render them in any language or UI format.
{
"id": "apr_abc123",
"status": "pending",
"identity": "spiffe://acme/user/alice/agent/henry",
"derived_keys": [
{ "key": "github:create_pull_request:repo=overfolder/backend",
"service": "github", "action": "create_pull_request",
"arg": "repo=overfolder/backend", "param": "repo", "value": "overfolder/backend" }
],
"suggested_tiers": [
{ "keys": ["github:create_pull_request:repo=overfolder/backend"],
"description": "Create pull request on repo overfolder/backend" },
{ "keys": ["github:create_pull_request:overfolder/backend"],
"description": "Create pull request on overfolder/backend" },
{ "keys": ["github:create_pull_request:*"],
"description": "Create pull request on any repo" },
{ "keys": ["github:*:*"],
"description": "Any GitHub action" }
]
}Each tier includes a description — an English human-readable label generated by Overslash from the service registry and key structure. Platforms can display it as-is, use it as fallback, or ignore it and build their own labels from the structured derived_keys parts for i18n.
For multi-key requests (e.g., http service with secret injection), keys within each tier broaden together as coherent sets — not as independent per-key choices. This keeps tiers to 2-4 options regardless of how many keys the request derives:
{
"derived_keys": [
{ "key": "http:POST:api.example.com", "service": "http", "action": "POST",
"arg": "api.example.com", "value": "api.example.com" },
{ "key": "secret:api_key:api.example.com", "service": "secret", "action": "api_key",
"arg": "api.example.com", "value": "api.example.com" }
],
"suggested_tiers": [
{ "keys": ["http:POST:api.example.com", "secret:api_key:api.example.com"],
"description": "POST to api.example.com with api_key" },
{ "keys": ["http:ANY:api.example.com", "secret:api_key:api.example.com"],
"description": "Any request to api.example.com with api_key" }
]
}The resolve endpoint accepts keys directly:
POST /v1/approvals/{id}/resolve
{
"resolution": "allow_remember",
"remember_keys": ["github:create_pull_request:*"],
"ttl": "24h"
}resolution can be allow (one-time, no keys stored), allow_remember (stores keys), or deny. remember_keys can be a suggested tier verbatim or a custom set — Overslash validates that the keys don't exceed the group ceiling.
Design principles:
- Overslash generates tiers; platforms render them. Each tier includes an English
descriptionthat platforms can display as-is or use as fallback. The structured parts (service,action,arg) inderived_keysgive platforms everything they need to build labels in other languages. - Suggested tiers are convenience, not a constraint. Platforms with 2 buttons can just use "Allow" + "Allow & Remember" (most specific tier). Platforms with more room can show multiple tiers. Overslash's own dashboard renders the full picker.
- 2-4 tiers max. Multi-key actions compose within tiers to avoid combinatorial explosion.
Within an org, access control determines which users can see and manage which resources. An ACL (Access Control List) or role-based system governs:
- Which users can view/manage specific services, connections, and secrets
- Which users can create and manage agents
- Which users can resolve approvals for other users' agents
- Org-admin vs member vs read-only roles
This is distinct from the permission key system (which gates action execution). ACL controls who can administer Overslash itself within an org.
Every write creates a new version. Latest is always used for injection. Earlier versions can be restored (creates a new version pointing to the old value). Version history records who created each version and when, enabling audit and confident rollback.
Secrets belong to the identity that created them. When agents set up integrations, they use on_behalf_of to create secrets at the owner-user level — so all agents under that user share them.
Secret values are encrypted at rest. Access to values depends on the actor:
| Actor | List names | Read values | Write |
|---|---|---|---|
| User (dashboard) | own subtree | own subtree | own subtree |
| Agent (API) | own subtree (names only, via bearer GET /v1/secrets) |
— | own subtree |
Org admin (User with is_org_admin = true) |
all org | all org | all org |
Each secret carries an explicit owner_identity_id (the identity that wrote v1, or on_behalf_of target). Visibility for non-admin callers is "the owner is the caller, or any descendant of the caller via identities.parent_id". The namespace is org-wide — (org_id, name) is unique — so two agents under the same user cannot mint the same name.
Org admin is an attribute on a User identity, not a separate principal. There is no standalone "org" identity that can authenticate or hold API keys — every authenticated caller is a User or an Agent. Agents earn admin authority the same way they earn any other permission: by being placed in a group with
adminaccess on theoverslashmeta service (a system-managedservice_instancethat represents Overslash itself within each org). Theis_org_adminflag is the fast path for Users and is kept in sync with membership of the system Admins group.
- Users can view and manage secret values for all secrets in their subtree (their own + their agents' secrets) via the dashboard.
- Agents can list the names of secrets in their own subtree via bearer-authenticated
GET /v1/secrets— the response is a narrow{name, version_count, last_rotated_at}shape with no values, no owner identity, and no creation timestamps. Reveal/restore/detail remain dashboard-only. Secret values are only injected at action execution time, gated by the permission chain. - Org admins can view and manage all secrets across the org. This follows the standard model for org-managed credential stores (same as 1Password Teams, AWS Secrets Manager, etc.) and is required for compliance, debugging, and offboarding scenarios.
Overslash handles OAuth flows (authorization URL generation, code exchange, token storage, automatic refresh) for services that use OAuth authentication. The OAuth engine is internal machinery — not a user-facing concept. Users interact with services (§9), which encapsulate their credentials.
OAuth client credentials resolve via a three-tier cascade. At execution time, the OAuth engine walks the cascade top-to-bottom and uses the first match:
-
User-level BYOC — the user provides their own OAuth app credentials for a provider, stored as versioned secrets in the user's vault with well-known names:
OAUTH_{PROVIDER}_CLIENT_IDandOAUTH_{PROVIDER}_CLIENT_SECRET(e.g.,OAUTH_GOOGLE_CLIENT_ID). This lets power users or contractors use their own GCP/GitHub/etc. project without touching org config. -
Org-level — org-admins configure OAuth app credentials for a provider at the org level, stored as org-level secrets with the same well-known naming convention. All users in the org inherit these credentials for services that use the provider. This is the recommended path for Google Workspace customers (see below).
-
Overslash system credentials — managed by instance operators via environment variables, used as defaults for all orgs. Covers consumer accounts and low-stakes scopes where a shared Overslash-verified app is acceptable.
If no credentials are found at any level, the connect flow shows an error explaining that no OAuth app is configured for this provider.
When a user creates a service from a template that uses OAuth, the connect flow walks them through the OAuth redirect. The resulting token is stored encrypted and bound to that service instance.
Orchestrated vs. imported connections. The flow above — overslash builds the authorize URL and completes the dance at GET /v1/oauth/callback — is the orchestrated path, used by normal orgs and the dashboard's own connect UI; it always uses the default {public_url}/v1/oauth/callback redirect. White-label partners that already own their OAuth (e.g., Overfolder) instead run the dance themselves and hand overslash the resulting tokens via POST /v1/connections/import — overslash is a token vault: it stores the tokens (identical connection row), refreshes them, and injects them at execution, but never issues a redirect_uri. The import requires a byoc_credential_id (the partner's registered client; a null pin is a 400): overslash self-refreshes autonomously, hard-pinned to that client — never the env/org OAUTH_*_CLIENT cascade, since a refresh token is valid only against the client that issued it.
Auth-recovery for white-label end users is governed by a per-org headless capability (orgs.headless, admin-only via GET/PATCH /v1/orgs/{id}/headless). A white-label org's end users have no Overslash dashboard session, so the gated /connect-authorize link normal orgs receive is a dead end for them. For a headless org, the three auth-recovery envelopes (reauth_required, needs_authentication, missing_scopes) are URL-less: they omit auth_url/short (and upgrade_url for missing_scopes), carry headless: true plus provider/required_scopes/account_email, and mint no oauth_connection_flows row. The integration reads the envelope, re-runs its own dance, and re-imports. Non-headless orgs keep the gated flow unchanged. (Separately, the secret-backed needs_authentication shape is URL-less for every org — a template that authenticates with vault secrets has no consent page to link — and carries missing_credentials + hint_url instead; see D60.) (This replaces the removed per-connection integration_managed flag, which conflated who refreshes with who runs the user flow.) Full design in docs/design/white-label-token-vault.md.
Provider-level credentials, not service-level. OAuth client credentials are scoped to the provider (e.g., google), not to individual services. Google Calendar, Google Drive, and Gmail all reference provider: google in their templates — they all share the same OAuth app credentials. Scopes differ per service, but the OAuth client is the same. This means an org that configures org-level Google credentials gets Calendar, Drive, and Gmail working with one setup.
IdP and service credential reuse. When an org configures Google as an IdP for login (§3) and also uses Google-based services (Calendar, Drive, Gmail), the same org-level secrets can serve both purposes. The IdP config (§3 org_idp_configs) and the OAuth engine both resolve to the same OAUTH_GOOGLE_CLIENT_ID / OAUTH_GOOGLE_CLIENT_SECRET org secrets. Org-admins configure Google credentials once — in Org Settings — and both login and service connections use them. This is intentional: a single GCP project with the right scopes covers both OIDC login and API access.
System credentials and verification. Overslash system credentials are subject to the upstream IdP's app-verification process. For Google in particular, sensitive scopes (Calendar, basic Gmail/Drive) require Google brand verification, and restricted scopes (full Gmail/Drive) require an annual CASA assessment by an authorized lab. This is expensive, slow, and recurs yearly. For Google Workspace customers, prefer per-org credentials — each Workspace admin creates their own GCP project, marks its OAuth consent screen as Internal, and provides client ID + secret to Overslash via Org Settings. Internal-tier clients require no Google verification regardless of scope. System credentials remain available as a default for low-stakes scopes and consumer accounts, but Workspace orgs should be onboarded via org-level credentials. (See docs/design/google-workspace-oauth.md for the full analysis.)
All action execution goes through a single endpoint. The caller specifies a service instance and action — the level of abstraction is determined by what they choose:
Service + defined action — the caller names a service instance and a template-defined action (e.g., github + create_pull_request). Overslash builds the HTTP request from the template definition. Auth auto-resolved from the service's credentials. Derives key: github:create_pull_request:repo={resource} (the label comes from the action's scope_param; see §5).
Service + HTTP verb — the caller names a service instance and an HTTP method + path (e.g., github + POST /repos/X/pulls). Auth is auto-injected from the service's credentials. For agents that know the API but want Overslash to handle auth. Derives key: github:POST:/repos/X/pulls.
http pseudo-service — the caller uses the http pseudo-service with a full URL, method, headers, body, and secret injection metadata. This is the lowest-level path — agents construct the full request. Requires http in the user's group. Derives keys: http:POST:api.github.com + secret:gh_token:api.github.com.
These are a spectrum of abstraction over the same execution pipeline and permission key format ({service}:{action}:{arg}).
How long a call may wait on its upstream is resolved from five layers, most specific first. The first one with an opinion wins:
| # | layer | where it lives |
|---|---|---|
| 1 | this call | timeout_ms on the request body (and on the overslash_call / overslash_read MCP tools) |
| 2 | this action | x-overslash-timeout_ms on the operation, after any org layer is folded in |
| 3 | this service | info.x-overslash-default_timeout_ms |
| 4 | this org | orgs.call_timeout_ms, via PATCH /v1/orgs/{id}/execution-settings |
| 5 | this deployment | CALL_TIMEOUT_MS (default 30000) |
The result is then clamped by orgs.max_call_timeout_ms and CALL_TIMEOUT_MAX_MS (default 110000). Caps combine by tightest wins, not by specificity — an org cannot raise itself above what the deployment allows.
A caller-supplied timeout_ms above the effective maximum is a 400 naming the ceiling; a template or org default above it is silently clamped. The asymmetry is deliberate: the caller is present and can act on the error, while a misconfigured template value that 400s every call in the org is a strictly worse failure than one that quietly runs at the ceiling.
Exceeding the budget returns 504 with {error: "upstream_timeout", timeout_ms, timeout_source, max_timeout_ms, hint}. timeout_source names the layer that set it (per_call, action_template, service_template, org_default, global_default, stored) — which is what turns "why is this timing out" into a one-line fix. An action.executed audit row is written either way, carrying detail.error.kind = "timeout".
CALL_TIMEOUT_MAX_MS is the synchronous ceiling and is sized to sit under the deployment's own request cap (Cloud Run and the load balancer both cut at 120s). Work that legitimately runs longer is not served by raising it.
Streaming is bounded differently. For prefer_stream: true the resolved timeout bounds time to first byte only; the transfer itself is bounded by a per-chunk idle timeout (CALL_STREAM_IDLE_TIMEOUT_MS, default 30000). A total deadline over a streamed body would mean "your 900MB export fails at exactly 90s", and would fire after the audit row recorded a 200 and the response headers were flushed — handing the client a silently truncated body.
Replays reuse the timeout resolved when the call was first made (stored on the approval), re-clamped against the org's current maximum — so tightening the ceiling binds retroactively rather than being outranked by a stale approval. Approvals created before this shipped carry no budget and replay at the deployment default.
Direct connection: <uuid> requests (a previously-shipped implementation deviation that paired a stored OAuth connection with an arbitrary URL) are not supported. Free-form authed calls go through "Service + HTTP verb" — naming the service instance is what bounds where the bearer can land via the template's hosts[]. See DECISIONS.md D14.
Every request derives permission keys. Resolution follows the two-layer model (§5):
- Group ceiling check (service + access level)
- Permission key check (all derived keys must be covered)
- If uncovered → approval request → user decides → "Allow & Remember" stores keys
When call_action returns pending_approval, the response includes a user-facing URL the agent surfaces to its owner (e.g., "please approve here: https://<dashboard>/approvals/<id>"). The URL points at the dashboard deep-link page (/approvals/{id}), which renders as a modal overlay on top of /agents after login. The host portion is resolved from the deployment-level DASHBOARD_URL envvar (served by overslash serve; overslash web uses the same-origin dashboard host) — never from the API's own Host header and never hardcoded. Agent-facing responses must not leak internal API hostnames or placeholder domains (overslash.example, api.*) to downstream LLM output. Self-hosted deployments set the envvar; cloud deployments pick it up from the Cloud Run/Vercel config.
When using the http pseudo-service, the caller specifies how each secret should be injected per-call (as header, query param, or cookie). This generates secret:{name}:{host} permission keys alongside the http:{METHOD}:{host} key. Both must be covered for auto-approval.
For service-based requests, auth is resolved automatically from the service instance's credentials — no manual secret injection needed.
For registry-known services, action descriptions support string interpolation with {param_name} placeholders that resolve to the actual arguments at execution time:
create_pull_request:
description: "Create pull request '{title}' on {repo}"
# → "Create pull request 'Fix bug' on overfolder/app"
list_pull_requests:
description: "List pull requests on {repo}[ with state {state}]"
# → "List pull requests on overfolder/app with state open" (both provided)
# → "List pull requests on overfolder/app" (state omitted)Optional params use conditional segments: [text with {optional_param}] — the bracketed segment is included only when all its placeholders are present. This avoids dangling "with state" fragments when optional params are omitted.
These descriptions appear in: approval requests (what the agent wants to do), audit log entries, specificity tier descriptions, and the API Explorer response panel.
Two distinct concepts:
- Service Template — an OpenAPI 3.1 definition describing an API: base URL, auth config, operations. No credentials. A blueprint.
- Service — a named instance of a template, bound to specific credentials.
work-calendaris a Google Calendar template instantiated with alice@acme.com's OAuth token.
Templates live in a three-tier registry:
| Tier | Managed by | Visible to | Mutable |
|---|---|---|---|
| Global | Overslash (shipped OpenAPI YAML) | Everyone | Read-only for orgs |
| Org | Org-admins | Org members | Full CRUD |
| User | Users (if org allows) | Creator + their agents | Full CRUD |
Global: OpenAPI 3.1 YAML files shipped with Overslash under services/. Common APIs (Eventbrite, GitHub, Gmail, Google Calendar, Google Drive, Slack, Stripe, Resend, X). Read-only for orgs. Org-admins can hide unused global templates from their org.
Org: Org-admins create templates for the org's internal or niche APIs. Visible to all org members (templates are blueprints — visibility doesn't grant access, creating a service instance does).
User: Users create personal templates for APIs only they use. Gated by org setting (user_template_policy: none | restrictive (reserved) | full). Private by default. Users can propose sharing a template to org level — org-admin reviews and approves or denies.
Org-admin visibility: Org-admins can see all templates in the org (global + org + user-created) in a read-only list for security/compliance — they need to know what external APIs their users are connecting to.
Layers (extends/delta). Each org/user template row is a layer. A standalone layer (extends NULL) holds a full OpenAPI doc (the classic org/user template). A derived layer (extends set) holds a delta over a base template named by extends (resolved by key: DB rows then the global registry), and its effective template is the fold resolve(layer) = apply(delta, resolve(extends)). The delta's masks are restrictive and order-independent — action allowlist (∩) / denylist (), per-action risk clamp-up-only, additive disclose, relabel, template hidden — so resolve(child) ⊆ resolve(base) (a child can never re-expose what a parent hid); its extensions add new actions/hosts only (no auth, no rebinding). An org layer may additionally carry instance_defaults — non-secret presets (endpoint url + x-overslash-instance-config values) that every service instance created from the layer inherits unless it sets its own, so an org running its own deployment (its overfwd Mailbox Gateway, its self-hosted MCP server) names it once instead of on every user's instance. extends is a live pointer: editing a base (or Overslash shipping a new global version) propagates to every descendant immediately. extends and the layer's own key are decoupled — reusing the base key shadows it (curation that agents get transparently), a distinct key is a separate catalog entry alongside the base. Discovery, instantiation, and execution all resolve through the fold, so a masked-out action is unreachable everywhere. See DECISIONS.md D26 and docs/design/layered-service-templates.md.
Templates are authored as OpenAPI 3.1 documents. The AI-gateway-specific fields that OpenAPI cannot express natively live under the x-overslash-* vendor-extension namespace: risk, scope_param, resolve, aliases, provider, default_secret_name, sql-field, sql-database, icon. For authoring ergonomics, the same keys may also be written without the prefix (just risk:, scope_param:, etc.) — the backend normalizes aliases to their canonical x-overslash-* form on load and before persist. Ambiguous documents (both forms present on the same object) are rejected with a stable ambiguous_alias error.
openapi: 3.1.0
info:
title: Google Calendar
key: google_calendar # alias for x-overslash-key
# icon: implicit — `google_calendar.svg` is shipped
servers:
- url: https://www.googleapis.com
components:
securitySchemes:
oauth:
type: oauth2
provider: google # alias for x-overslash-provider
flows:
authorizationCode:
authorizationUrl: https://accounts.google.com/o/oauth2/v2/auth
tokenUrl: https://oauth2.googleapis.com/token
scopes:
https://www.googleapis.com/auth/calendar: ""
paths:
/calendar/v3/calendars/{calendarId}/events:
parameters:
- name: calendarId
in: path
required: true
description: "Calendar identifier (use 'primary' for the main calendar)"
schema:
type: string
default: primary
resolve: # alias for x-overslash-resolve
get: /calendar/v3/calendars/{calendarId}
pick: summary
aliases: [calendar, cal] # alias for x-overslash-aliases
post:
operationId: create_event
summary: "Create event '{summary}' on calendar {calendarId}"
risk: write # alias for x-overslash-risk
scope_param: calendarId # alias for x-overslash-scope_param
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [summary, start, end]
properties:
summary: {type: string, description: Title of the event}
start: {type: object, description: "Start time object"}
end: {type: object, description: "End time object"}Key gateway-specific fields:
x-overslash-risk/risk:— enum:read,write,delete,dynamic. Defaults to a value inferred from the HTTP method (GET/HEAD/OPTIONS → read, DELETE → delete, else write). Decides which rung of a grant'sauto_approve_levelthe action falls under.dynamic(D42/D43) means "classified per call from the SQL the caller supplies": valid only on an action with anx-overslash-sql-fieldparam, presented aswritein static contexts (write until proven read), and resolved at call time to the parser's verdict — a build without thesql_policyfeature, an unsupported dialect, or an unparseable statement all fail closed to write.x-overslash-scope_param/scope_param:— which parameter(s) provide the{arg}segment in permission keys. Without it, the arg is*. Accepts a param name (scope_param: repo), aparam:labelpair, or a list of either (scope_param: [to:recipient, cc:recipient, bcc:recipient]). Each value mints one{service}:{action}:{label}={value}key — an array-valued param fans out per element — and the label defaults to the param name. Keys are deduped, so params sharing a label collapse a value that appears in more than one of them into a single key (and a single approval). See §5 for how rules match labelled keys.x-overslash-sql-field/sql-field:— on a parameter, a dotted body path that both nominates this param as the raw-SQL field (D42/D43 content policy: parse → read/write risk floor; per-table permission keys split by context — read-context relations mint{service}:{action}:table={label}/{relation}, mutation targets minttable_mut={label}/{relation}, plus the mutation-shaped all-tables sentineltable_mut={label}/*when relations can't be enumerated; the label-less value-only form covers both classes;column=/column_star=deny screening) and names where the SQL string sits in the assembled JSON body. On a string param the value is placed at the path (native.querykeeps the caller surface flat while nesting the outgoing body); on an object param the path is descended into (it must anchor at the param's own name). One per action, enforced at template compile.x-overslash-sql-database/sql-database:— a jq expression over the call params (e.g..database | tostring) whose result keys into the instance'ssql_databasesconfig map ({"<db-key>": {"dialect": "...", "label": "..."}}) to resolve the parse dialect and the human DB label used in audit rows and permission keys. Unresolved falls back to postgres with the raw key as label (fail-closed).x-overslash-resolve/resolve:— on a parameter, fetch a human-readable name for an opaque ID before the approval is minted. Declares a target and a projection. The target is eitherget:(HTTP runtime — a follow-up authenticated GET against the same service host) ortool:+args:(MCP runtime — atools/callagainst the same instance; the named tool must berisk: read, since a resolver runs before a human has seen anything). The projection is eitherpick:(one dot-path into the response) ordisplay:(a{dot.path}template sharing the description grammar, so{name}[ ({phone})]drops the bracketed segment when the phone is unknown). Optionalscope:names a dot-path whose value canonicalizes the permission key — see §5. Optionalcache_ttl:(seconds) is how long this resolver's answer may be reused;0opts out. Resolution is best-effort: a failed or slow lookup (3s timeout) degrades the approval's readability and never blocks it. Feeds agent-facing descriptions and the disclosure.resolved.*projection (§12a). Available onparameters[], body-schema properties, and MCPinput_schemaproperties.cache_ttl:(inside aresolveblock) — how many seconds a resolver's answer may be reused, overriding the deployment'sRESOLVE_CACHE_TTL_SECS. A default, not a cap: the deployment's ceiling still clamps it, and clamps harder when the resolver declaresscope:(RESOLVE_CACHE_SCOPE_TTL_MAX_SECS).0disables caching for this resolver alone. Set it high only where the mapping is genuinely immutable (me→ your own address); ascope:-bearing resolver decides which permission grant matches while the call still targets the caller's raw argument, so a wide window is a window in which a grant can be matched against a stale mapping — the linter warns (resolver_cache_ttl_wide). See D64.x-overslash-aliases/aliases:— on a parameter, a list of alternate caller-facing names (e.g.[to, dest]on arecipientparam). A call that supplies an alias key instead of the canonical name has it rewritten to the canonical name before validation, so a well-known synonym is accepted rather than rejected as an unknown argument. A declared field always wins over an alias that collides with it, and an alias claimed by two params is ambiguous and ignored (the caller gets the normal unknown-argument error, with a Levenshtein "did you mean" suggestion). The unprefixedaliases:form is normalized onparameters[]entries; body-schema properties use the canonicalx-overslash-aliaseskey (same asresolve).x-overslash-provider/provider:— on anoauth2security scheme, the symbolic OAuth provider name (google,slack,github, ...). Decoupled from OAuth URLs so the gateway can resolve credentials independently.x-overslash-default_secret_name/default_secret_name:— on anapiKeyorhttpsecurity scheme, the canonical secret name for auto-wiring. Templates are expected to declare either an OAuth scheme or an apiKey/http scheme with this field — OAuth templates don't fall back to a secret.x-overslash-timeout_ms/timeout_ms:— on an operation (or MCP tool), how long that action is expected to need upstream, in milliseconds. A default, not a cap: it encodes knowledge about the upstream ("Metabase aggregations are slow"), and the org and deployment maxima still clamp it. Omitted, the action inherits the service default, then the org default, then the deployment default. A value that is present but not a positive integer is a template error, not a silent fallback. See §8 for the full cascade.x-overslash-default_timeout_ms/default_timeout_ms:— underinfo, the same thing one rung less specific: the timeout every action of this service inherits unless it declares its own. The one-line answer to "this whole upstream is slow".x-overslash-icon/icon:— underinfo, the mark the dashboard shows for this service. Two forms:builtin:<name>, an asset Overslash ships and serves at/icons/<name>.svg, and anhttps://URL hosted elsewhere. Usually omitted: a template whose key matches a shipped asset resolves tobuiltin:<key>implicitly, which is why the shipped templates declare nothing. Explicit values are for the two cases the convention can't express — a key that deliberately differs from the asset it reuses (github_legacy_oauth), or a remote URL. Resolved server-side and surfaced as an absoluteicon_urlon the template and service-instance responses; a template with nothing renderable omits the field, and the dashboard falls back to a letter tile. Onlyhttps://ever reaches a browser —http:,data:andjavascript:are template errors, checked both at write time and again when the response is built. Overslash never fetches a remote icon (that would be an SSRF vector and a boot-time network dependency), so there is no size or format validation of one.${VAR}expansion applies like anywhere else, which is how a self-hoster points the set at their own CDN. Icons are deliberately not on/v1/search: it fans out up to 100 rows per (instance × action) and the field would cost an agent's context window for something no model can render.- Platform-namespace actions —
x-overslash-platform_actions(aliasplatform_actions:) at the top level declares permission anchors with no HTTP binding (e.g. theoverslashmeta service's admin actions).
Service-level scopes: under the oauth auth block is the superset of scopes the service can request. What's actually granted by the provider is stored on the connection — the OAuth token response returns the accepted scope value, which is persisted in connections.scopes and is the ground truth for what the access token can do.
Per-action scopes (planned). A single connection doesn't always need every scope the service knows about — Gmail's list_messages only needs gmail.readonly, while send_message needs gmail.send. The planned model:
auth:
- type: oauth
provider: google
scopes:
- https://www.googleapis.com/auth/gmail.readonly
- https://www.googleapis.com/auth/gmail.send
- https://www.googleapis.com/auth/gmail.modify
actions:
list_messages:
required_scopes: [https://www.googleapis.com/auth/gmail.readonly]
# …
send_message:
required_scopes: [https://www.googleapis.com/auth/gmail.send]
# …At connect time, the caller picks which scopes to request from the service's superset (e.g., "read-only" vs "full"). At execution time, Overslash checks connections.scopes ⊇ action.required_scopes before dispatching; on mismatch it fails fast with an upgrade hint ("reconnect with X scope") instead of letting the provider 403. This enables minimal-privilege connections and progressive reconnection when an agent reaches an action it isn't scoped for. required_scopes defaults to the service-level set when omitted (current behavior).
Scope catalogs from upstream. Scopes are hand-declared in YAML today. For large providers (Google, GitHub, Stripe) they can be codegen'd from Google Discovery documents or OpenAPI 3.x security annotations at build time — the YAML stays the runtime source of truth, the tool just keeps us honest as upstream APIs evolve.
Templates whose auth.type is secret declare two extra fields to make the secret-provide flow usable:
key: linear
display_name: Linear
hosts: [api.linear.app]
auth:
- type: secret
instructions: "Paste your Linear personal API key. Find it at https://linear.app/settings/api"
injection: { as: header, header_name: Authorization, prefix: "Bearer " }
verify:
method: GET
path: /viewer
expect_status: 200-
instructions— a short human-facing string telling the user where to get the secret and what kind of secret it is. Rendered verbatim on the/secrets/provide/...page above the input field, and returned in thecreate_service_from_templateresponse so the agent can mention it when surfacing the URL. Without this, users staring at a "paste secret here" page have no idea what's expected. Required for secret-token templates. -
verify— an optional pre-flight HTTP probe fired immediately after the user submits the secret, before flipping the service toactive. If the probe succeeds, the service goesactive. If it fails (401, 403, network error), the service stays inpending_credentials, the error is shown on the same secret-provide page, and the user can paste a new value without restarting the flow (the row's JWT is re-issued in place). The annoying failure mode for secret services is "user typo'd the key, agent doesn't find out until hours later" —verifycloses that gap. Opt-in per template because some upstream APIs charge for every request or rate-limit auth checks aggressively; for most APIs aGET /viewer-style probe is free and fast.
Multi-secret templates (e.g., AWS access key ID + secret access key) declare multiple slots under the auth block; the secret-provide page renders one input per slot and submission is atomic.
A service is created by instantiating a template with a name and credentials:
Template: Google Calendar
↓
Service: "google-calendar" (OAuth token for alice@acme.com — org default)
Service: "google-calendar" (OAuth token for alice@gmail.com — user, shadows org)
Service: "client-calendar" (OAuth token for alice@bigclient.org — user, different name)
Service ownership:
- Services have an optional
owner_identity_idused purely as a namespace and provenance marker.NULLmeans the service lives in the org namespace (e.g.,github); a non-null value puts the service in that user's private namespace (e.g., alice'smy-scraper). - Permission and visibility flow through
group_grantsuniformly regardless ofowner_identity_id. An owner-created service is auto-granted to that user's Myself group withaccess_level = 'admin'andauto_approve_level = 'read', which is what makes it reachable. Org admins can additionally grant any service — owner-namespaced or not — to other groups for sharing. - Agents that create services with the default
user_level: truecreate them under their owner-user's namespace (matching the SPEC rule that agents create resources at owner-user level so all sibling agents share them). Passuser_level: falseto create an org-namespaced service oron_behalf_of: <user>to target a specific owner. - An org-level create must name its groups.
user_level: falseproduces a service with no owner and therefore no Myself group — a grant is the only path to it, soPOST /v1/servicesrequires a non-emptygroups: [{ group_id, access_level, auto_approve_reads }]and rejects the request otherwise. At least one named group must be one the creator belongs to (resolved through their ceiling user), so an admin can't strand a service outside their own reach. Myself groups are rejected here: they are auto-managed and only ever grant their owner's own services.
Naming and resolution:
Service names default to the template key in lowercase (e.g., template "GitHub" → service github). Names are scoped: org services and user services can share the same name.
Resolution uses user-shadows-org: when a user has a service with the same name as an org service, the user's instance takes precedence. To explicitly reference the org instance, use qualified syntax: org/github.
github→ user'sgithubif exists, else org'sgithuborg/github→ explicitly the org's instance
This lets users override org defaults with their own credentials (e.g., personal GitHub account instead of org's) simply by creating a service with the same name.
Qualified vs unqualified names by context:
| Context | Format | Example | Why |
|---|---|---|---|
| Permission keys | unqualified | github:create_pull_request:* |
Follows resolution, no pinning to a specific scope |
| Group grants | FK to service instance | github (write) | Direct reference to org-level service + access level |
| Audit log | fully qualified | org/github:create_pull_request:overfolder/app |
Forensic record — must show exactly which instance and credentials were used |
| Approval display | scope-qualified | user/github or org/github |
User needs to know which credentials the agent will use (user/ is sufficient — the user knows who they are) |
| API requests | unqualified (default) | github |
Resolution applies; org/github available to bypass shadow |
Permission keys use the unqualified name and follow the same resolution:
github:create_pull_request:overfolder/*— resolves through the user'sgithubif it shadows, else the org'sgoogle-calendar:list_events:*
Groups grant access to service instances (any service in the org, regardless of owner_identity_id):
- Engineering group gets: github (write), slack (write)
- Service discovery is group-gated:
GET /v1/servicesreturns the union of services the caller's ceiling user has grants on — across the Myself group (owner-namespaced services), the Everyone group, and any admin-created groups they belong to. - Owner-created services are reachable through the owner's Myself grant. Org admins can layer additional grants on top to share an owner-namespaced service with other groups.
Service lifecycle: see Service Lifecycle States below.
A service instance moves through a small state machine. The same machine applies whether credentials come via OAuth, secret token, or no credentials at all (shared/free APIs).
| State | Meaning | Visible in overslash_search? |
Cleanup |
|---|---|---|---|
pending_credentials |
Created, awaiting OAuth callback or secret submission via the credential flow URL | No — would pollute the catalog and let other agents try to use it | TTL: 15 minutes, then deleted |
active |
Ready to use; credentials stored and (optionally) verified | Yes | — |
error |
OAuth denied, scopes insufficient, secret rejected, or credential verification failed in a non-recoverable way | No | TTL: 24 hours for forensic visibility, then deleted |
archived |
Soft-deleted, hidden from discovery; audit log + remembered approvals preserved | No | Manual restore or hard-delete by owner |
There is intentionally no Draft state. A service is either configured-and-active or it is not. To test an active service before exposing it to agents, set the per-service flag exposed_to_agents: false — overslash_search filters it out for agent identities but the API Explorer can still call against it as the owner-user.
pending_credentials is a single state with a flow_kind: "oauth" | "secret" discriminator on the row. The lifecycle code has one path; only the credential-redemption surfaces (OAuth callback handler vs /secrets/provide/... page) differ.
Pending visibility: the owner-user sees pending services in the dashboard with a "Connecting…" badge and a "Cancel" button (manual delete before TTL). The creating agent sees its own pending services via overslash_auth(action="status"). No other identity in the org sees them.
Executing against a pending service returns service_not_ready, distinct from not_authorized. Agents should poll status (or subscribe via SSE) instead of retry-spamming call.
Retrying a failed credential flow: overslash_auth(action="retry_credentials", service=...) works on rows in pending_credentials (extends TTL, mints a fresh URL, invalidates the previous one) or error (flips back to pending_credentials, mints a fresh URL). The service ID and name are preserved across retries — the dashboard's "Connecting…" view stays continuous.
Concurrent flows on one row: the OAuth state value or secret JWT is single-use. retry_credentials purges the previous one before minting a fresh one, preventing replay races where two browser tabs could finish a flow.
OAuth scope downgrade: if the user grants only a subset of requested scopes, Overslash records the actually granted scopes on the service and flips to active. overslash_search returns the service's actions list filtered to the granted scopes — the agent sees a smaller surface than the template advertises and can decide what to do.
Name conflicts at create time: if the owner already has a service (active or pending) with the requested name, create_service_from_template returns 409 conflict with the existing service ID. No auto-suffixing — the agent loses track of names. The agent can pick a different name or call retry_credentials against the existing pending row.
- Pick a template (from global/org/user templates)
- Name the service instance — defaults to the template key (e.g.,
google-calendar). Rename to create additional instances (e.g.,personal-calendar). - OAuth client override (optional) — for templates that use OAuth, the user can optionally provide their own OAuth app credentials (client ID + client secret). If provided, these are stored as secrets
OAUTH_{PROVIDER}_CLIENT_ID/OAUTH_{PROVIDER}_CLIENT_SECRETin the user's vault and used instead of org or system credentials for this user's connections to this provider. If omitted, the cascade (§7) resolves credentials normally. - Connect credentials — OAuth flow, secret input, or shared credential (for org services)
- Optionally assign to groups (org-admin only)
For org services with OAuth (per-user tokens): the org-admin configures the org's OAuth app credentials as org-level secrets (OAUTH_{PROVIDER}_CLIENT_ID / SECRET, configured in Org Settings → OAuth App Credentials). Users in the assigned groups see the service and complete their individual OAuth flow using the org's app credentials. The service is shared, but each user has their own token.
The dashboard flow above has an exact REST counterpart: agents can instantiate templates without any human dashboard interaction. This is the path used by the meta tool overslash_auth(action="create_service_from_template", ...) (§10).
Authority rules:
- An agent can create services on behalf of its owner-user via
on_behalf_of(§6 Scoping) — the resulting service is owned by the user, shared across all agents in that subtree. - An agent cannot create org-level services. Only org-admins (acting as users) can, and only by naming at least one group they belong to (see Service ownership above).
- The calling identity must have the template visible to it (§9 Tier visibility).
The creation call returns one of:
- OAuth-based template → an OAuth start URL the user must visit. The service is created in a pending state pending the OAuth callback.
- Secret-based template (API key, bearer token) → a signed secret-provide URL the user must visit. The service is created in a pending state pending secret provisioning. (See §11 Standalone Pages.)
- Shared/no-credential template → the service is created
Activeimmediately.
Once the user has supplied credentials at the returned URL, the service flips to Active and the agent learns about it via polling, SSE (§10 Async event delivery), or webhook. From the agent's perspective, the entire onboarding of a new integration is: search → auth.create → surface URL to user → poll for active → call. No dashboard required.
Upload an OpenAPI 3.x spec (file or URL) → Overslash parses it and stores a draft template with actions and parameter schemas. Available at both org and user tier. Because the template format is already an OpenAPI 3.1 superset, import is a mapping/augment pass rather than a translation: fetch → parse YAML/JSON → dereference local $refs → synthesize missing operationIds → apply overrides (key, display_name) → optionally filter to the selected operations → normalize aliases to canonical x-overslash-* form → lenient compile.
Drafts are DB-backed (a service_templates row with status='draft'), not client-side state. This is the only way the flow works for agents invoking the REST API / MCP without a browser session: import in one call, promote in another. Dashboard users get the same benefit — half-finished imports survive browser reloads.
Endpoints:
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/templates/import |
Create a draft from a source. Accepts draft_id to replace an existing draft's source without re-creating the row. |
GET |
/v1/templates/drafts |
List caller-visible drafts (org drafts for admins, user drafts for their owner). |
GET |
/v1/templates/drafts/{id} |
Fetch draft detail for review/edit. |
PUT |
/v1/templates/drafts/{id} |
Replace the draft's YAML (manual edits). Re-runs the lenient validator. |
POST |
/v1/templates/drafts/{id}/promote |
Run the strict validator; on success flip status='active'. Fails closed with TemplateValidationFailed if the draft still has errors. |
DELETE |
/v1/templates/drafts/{id} |
Discard. |
Drafts are invisible to GET /v1/templates, overslash_search, the runtime registry, and service-instance creation — they cannot be instantiated until promoted. The unique-key index on service_templates is scoped to WHERE status='active' so a draft can coexist with the template it plans to replace.
Request shape:
{
"source": { "type": "url", "url": "https://example.com/openapi.yaml" }
| { "type": "body", "body": "...", "content_type": "application/yaml" },
"include_operations": ["list_widgets", "create_widget"], // optional; default = all
"key": "my-widgets", // optional override of info.x-overslash-key
"display_name": "My Widgets", // optional override of info.title
"user_level": false,
"draft_id": null // pass to update an existing draft in place
}Response shape (shared by import, get-draft, put-draft):
{
"id": "<uuid>",
"tier": "org" | "user",
"openapi": "<canonical YAML string>",
"preview": { "key": "...", "display_name": "...", "hosts": [...], "auth": [...], "actions": [...] },
"validation": { "valid": false, "errors": [...], "warnings": [...] },
"import_warnings": [
{ "code": "derived_key", "message": "...", "path": "info.x-overslash-key" },
{ "code": "derived_operation_id", "message": "...", "path": "paths./widgets.post.operationId" },
{ "code": "openapi_3_0_source", "message": "...", "path": "openapi" },
{ "code": "unresolved_external_ref","message": "...", "path": "..." },
{ "code": "http_insecure", "message": "...", "path": "source.url" }
],
"operations": [
{ "operation_id": "list_widgets", "method": "get", "path": "/widgets",
"summary": "...", "included": true, "synthesized_id": false }
]
}preview may be null when the source didn't compile cleanly; validation.errors explains why. The draft still persists so the user can fix it in the editor and re-save.
URL-fetch policy (for source.type == "url"):
- Accept
https://silently;http://is accepted with anhttp_insecurewarning (rendered as a dashboard banner). - DNS-resolve the host up-front and reject if any resolved address is loopback, private (rfc1918, fc00::/7), link-local (169.254/16, fe80::/10), multicast, unspecified, broadcast, documentation, carrier-grade NAT (100.64/10), or IPv4-mapped private v6. Sidesteps basic DNS-rebinding.
- Manual redirect handling, max 3 hops, each hop re-validated.
- 10-second connect + read timeout; 512 KiB body cap (same as
POST /v1/templates/validate).
Partial import / selection: include_operations takes operationIds (or the synthesized {method}_{path-slug} id for operations missing one). The response always enumerates every operation from the source with an included flag — the dashboard renders a checkbox tree so users refine selection without re-parsing the source. Unchecking an operation and re-submitting with the new include_operations (+ the same draft_id) rewrites the draft's YAML.
OpenAPI 3.0 inputs are accepted with a warning but not translated — schema objects using JSON-Schema-draft-04 semantics may fail the strict validator at promote time. Users fix those inline before promoting.
The template YAML is parsed and validated by a pure-Rust linter in overslash-core::template_validation. The same linter is used by:
- Backend:
POST /v1/templates/validate— accepts raw YAML in the request body, always returns HTTP 200 with aValidationReport. YAML parse errors and duplicate mapping keys are themselves reported as validation issues rather than transport-level 4xx responses, so the dashboard editor can render diagnostics inline on every keystroke. - CRUD hook:
POST /v1/templatesandPUT /v1/templates/{id}/managerun the same validator over the JSON-encodedauth/actionsfields before writing to the database. A rejected save returns400with a{"error": "validation_failed", "report": {…}}body matching the validate-endpoint shape. - Registry loader: shipped
services/*.yamlfiles are validated at startup; a broken template is logged loudly and skipped (CI also runs a smoke test asserting every shipped template validates clean). - Dashboard: calls the validate endpoint for linting. The linter core has no YAML, DB, or I/O dependencies — a WASM feature gate (
overslash-core/yaml) is already in place so the module can be compiled to WASM for instant client-side validation once the dashboard wires it up.
Response shape (same for the endpoint and the CRUD error body under report):
{
"valid": false,
"errors": [
{ "code": "unknown_scope_param", "message": "...", "path": "actions.list_events.scope_param" }
],
"warnings": [
{ "code": "risk_method_mismatch", "message": "...", "path": "actions.list_events.risk" }
]
}Rules (errors unless marked warning):
| Code | What it catches |
|---|---|
missing_field |
required field (key, display_name, description, a resolve projection — pick or display —, path on HTTP actions) is empty |
invalid_key |
service key does not match ^[a-z][a-z0-9_-]*$ |
invalid_action_key |
action key does not match ^[a-z][a-z0-9_]*$ |
invalid_host |
host is empty, contains scheme, path, or whitespace |
unknown_auth_type |
auth[i].type is not oauth or secret (also surfaces as a schema_error on JSON input) |
incomplete_token_injection |
token_injection.as="header" without header_name, or "query" without query_param |
invalid_token_injection |
token_injection.as is not "header" or "query" |
invalid_http_method |
action method is not one of GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS |
invalid_path_syntax |
action path does not start with / or has an unclosed { placeholder |
unknown_path_param |
{param} in path does not reference a defined param |
path_param_not_required |
{param} in path references a param not marked required: true |
invalid_param_type |
params.<name>.type is not one of string, number, integer, boolean, array, object |
invalid_enum_values |
enum is empty, or default is set but not a member of enum |
unbalanced_brackets |
description or resolve.display has an unbalanced or nested [ (segments are flat only) |
invalid_description_syntax |
description has an unclosed { placeholder |
invalid_path_syntax |
resolve.get, a resolve.args value, or resolve.display has an unclosed { placeholder |
unknown_description_param |
{param} in description does not reference a defined param |
unknown_resolver_param |
{param} in resolve.get or a resolve.args value does not reference a defined param on the same action |
invalid_resolver_target |
resolve declares neither or both of get / tool; args on a get resolver; or a target that does not match the service runtime (get: on MCP, tool: on HTTP) |
invalid_resolver_projection |
resolve declares both pick and display |
invalid_resolver_scope |
resolve.scope on an array param — each element mints its own key, so one canonical value cannot replace the list |
unknown_resolver_tool |
resolve.tool does not name a tool on this service (matched on the wire name, i.e. mcp_tool when it differs from the action key) |
invalid_resolver_tool |
resolve.tool names a write/delete action — resolvers run before approval and must be read-only |
invalid_resolver_cache_ttl |
resolve.cache_ttl is present but not a non-negative integer number of seconds |
resolver_cache_ttl_wide |
(warning) resolve declares scope with a cache_ttl over 300s — a grant can then be matched against a mapping that stale while the call still targets the raw argument |
unknown_scope_param |
a scope_param entry does not reference a defined param |
invalid_scope_param |
scope_param is not a param name / param:label pair / list of them |
invalid_response_type |
response_type is set to something other than "json" or "binary" |
duplicate_action_key |
the actions: mapping in YAML defines the same key twice |
yaml_parse |
YAML source could not be parsed (wrapped serde_yaml error) |
schema_error |
JSON input (CRUD path) for auth or actions is structurally malformed |
risk_method_mismatch (warning) |
read-only HTTP method (GET/HEAD/OPTIONS) is annotated with risk: write or risk: delete |
unknown_extension (warning) |
an x-overslash-* key nothing in the gateway reads — a typo, or a name that only ever existed in a design doc |
misplaced_extension (warning) |
a real extension at a position whose extractor does not read it (e.g. x-overslash-download on an HTTP operation — it is MCP-only) |
unprefixed_alias_ignored (warning) |
the bare spelling of an extension at a position the alias normalizer does not rewrite (e.g. secrets: under components) |
unknown_template_key (warning) |
an unrecognized non-x- key at a position whose fields are enumerated (this is what catches response_type: on an operation) |
Keys nothing reads (D67). The four warnings above come from
openapi::lint_extensions, which runs on the alias-normalized document at every
entry point. They are warnings on every path, never errors: an error at
registry load would skip the template, and a missing service is worse than an
ignored field, while an error on update would make an already-active stored
template un-saveable. shipped_services_lint_clean is what keeps a shipped
template from regressing, and template_resolve re-reports them against the
stored document so a row written before the lint existed becomes visible.
Position is authoritative, not just spelling: openapi::ext records which
position reads each extension, and every extractor reads through it. Positions
whose sibling keys are vocabulary Overslash does not own — request-body and MCP
tool-input schema properties, a pasted discovered_tools snapshot, a
platform-action param, an unrecognized security-scheme type — are open-world
for bare keys, so a payload field genuinely named risk or template is never
reported. Foreign vendor extensions (x-amazon-*, x-ms-*) are always ignored.
Grammar notes. [optional segment] in descriptions is flat only — nested [ inside [...] is rejected. A {param} placeholder inside a description or [...] segment must reference a param defined on the same action. The runtime interpolator in overslash-core::description uses the same shared grammar primitives (overslash-core::description_grammar) as the linter, so "runtime accepts it but linter doesn't" drift is not possible.
Platform namespace templates. Services with empty hosts and actions that omit method/path (e.g. shipped services/overslash.yaml) are explicitly supported. An action with an empty method is treated as a non-HTTP permission anchor and the HTTP-specific rules are skipped for it — description and scope_param are still validated.
Request body size. POST /v1/templates/validate caps the body at 512 KiB. Larger payloads return 400 Bad Request without running the validator.
A small tool set that lets any LLM agent use Overslash. These are the underlying tools surfaced by both the CLI (overslash subcommands) and the MCP server (the POST /mcp HTTP transport, optionally fronted by the overslash mcp stdio shim — see §3 Integration Surfaces); REST callers invoke the same operations directly. All four are surface-agnostic; the credential column reflects what kind of identity the call is meaningful from.
| Tool | Purpose | Credential | Surfaces |
|---|---|---|---|
overslash_search |
Discover services and actions. Returns schemas + auth status. | agent (or user) | REST, CLI, MCP |
overslash_call |
Call any action (all three modes). Returns result or pending_approval. Called with {approval_id} to resume a previously-approved action and receive the replay result — see §5 Replay Semantics. |
agent (or user) | REST, CLI, MCP |
overslash_auth |
Check/initiate auth, store/request secrets, create sub-identities, instantiate templates. | agent (or user) | REST, CLI, MCP |
overslash_approve |
Resolve a pending approval (one-time, "Allow & Remember", bubble, or reject). See §5 Approval Bubbling. | user (an agent cannot approve its own requests) | REST, CLI, MCP |
When the MCP session is OAuth-authenticated as a user (the default), Layer 2 is skipped entirely, so overslash_call returns results directly without ever producing a pending_approval for overslash_approve to resolve. The tool exists for the inverse direction — a user surface (dashboard, CLI, or an MCP session in user mode) resolving approvals raised by an agent identity elsewhere in the org. Platforms that wrap the agent surface handle approval plumbing themselves (webhook/polling/SSE → their own user UX → REST POST /v1/approvals/{id}/resolve).
Unified discovery endpoint. Backed by GET /v1/search and called by the MCP overslash_search tool. The response is a single ranked list of rows where each row corresponds to one configured instance: the top-level service field is always the instance name to pass back as overslash_call.service. When a template has multiple connected instances (e.g. two Gmail accounts), it fans out into one row per instance so the agent picks the callable identifier directly without nested lookups.
Inputs
| Field | Type | Default | Behavior |
|---|---|---|---|
query |
string | — | Free-text query. Empty string triggers browse mode (instances-only, no actions). |
include_catalog |
bool | false |
Default: only configured instances bound to the caller are returned. Set true to also surface un-connected templates as setup_required: true rows (handy when an agent is exploring what could be set up). |
exclude |
string | — | Comma-separated list of services to omit. Each entry matches against both the instance name (e.g. gmail_work) and the template key (e.g. gmail); a single entry can drop one instance or every instance of a template. Whitespace around entries is trimmed. Applied before scoring + limit truncation so excluded rows never displace useful ones inside the response window. Equally honored in browse, keyword, and include_catalog modes. |
Default scope is connected-only. This applies to both browse (query="") and keyword queries. The motivation is that an agent is much more often trying to use what it already has than browse a catalog it hasn't been authorized for. setup_required: true rows only ever appear under include_catalog=true.
Response shape
{
"query": "send email",
"results": [
{
"service": "gmail_work",
"template": "gmail",
"service_display_name": "Gmail",
"account_email": "alice@example.com",
"action": "send_message",
"description": "Send email as {userId}",
"risk": "write",
"tier": "global",
"params": [
{ "name": "userId", "type": "string", "required": true, "description": "The sending account, or `me`." },
{ "name": "maxResults", "type": "integer", "description": "Page size.", "default": 100 }
],
"auth": { "type": "oauth", "provider": "google", "connected": true },
"score": 0.78
},
{
"service": "gmail_personal",
"template": "gmail",
"service_display_name": "Gmail",
"account_email": "alice@gmail.com",
"action": "send_message",
"description": "Send email as {userId}",
"risk": "write",
"tier": "global",
"auth": { "type": "oauth", "provider": "google", "connected": true },
"score": 0.78
},
{
"service": "resend_prod",
"template": "resend",
"service_display_name": "Resend",
"secret_name": "resend_prod",
"action": "send_email",
"description": "Send email '{subject}' to {to}",
"risk": "write",
"tier": "global",
"auth": { "type": "secret", "connected": true },
"score": 0.74
}
]
}In browse mode (query=""), each row omits action, description, risk, params, and score — the response is an instance-level directory.
params — the action's caller-supplied contract (action rows only). Each entry carries name, type, required (omitted when false), description (clamped to 160 characters), enum, and default. Ordered required-first, then alphabetically, so byte-identical requests return byte-identical JSON.
This is the answer to a specific failure: before it existed, description was the only string about an action that ever reached the model, so a paging parameter a template declared was undiscoverable unless its prose happened to restate it — an agent facing a list endpoint had no way to see that a narrower call was available. Note default in particular: a declared default is injected into the arg map at call time, so it is what the caller gets when it omits the parameter.
Parameters marked instance-config are excluded. An org admin pins those per service instance and they are merged in under the caller's arguments at execution time, so listing them would only invite a wrong one.
Catalog rows. Under include_catalog=true, templates with no configured instance for the caller appear as catalog rows: they omit service and account_email/secret_name, set auth.connected: false, and carry "setup_required": true. Agents must call overslash_auth.create_service_from_template to provision an instance before any of those actions become callable.
Per-row disambiguation
Each callable row carries everything an agent needs to pick the right instance:
service— the instance's runtime name (e.g.gmail_work). Unique per(org, owner, name). Pass it verbatim asoverslash_call.service.template— the underlying template key (e.g.gmail). Lets the agent recognise thatgmail_workandgmail_personalare siblings.account_email— for OAuth-backed instances, the email returned by the upstream userinfo endpoint at OAuth time. Sourced fromconnections.account_email. Absent for secret-based services.secret_name— for secret-based instances, the variable name of the secret that backs the instance. The value, version, and encryption envelope are never exposed via this or any other API.
When two instances share the same account_email (two services pinned to one OAuth connection) or the same secret_name, the service (instance name) is the disambiguator — it always uniquely identifies the row.
Action definitions are DRY; rows are not. Actions are defined once on the template (in the global YAML under services/ or in service_templates.openapi). A keyword match against (template, action) fans out into one row per visible instance so the agent can pick a callable directly; the underlying definition is shared.
Visibility matches the rest of the API: identity-bound calls apply Layer 1 group ceiling and tier visibility (global / org / user, gated by user_template_policy). Hidden global templates and out-of-ceiling instances never appear in either default or include_catalog=true output.
Search is cheap and idempotent by design. Agents are expected to re-query rather than maintain client-side state. There is no subscribe API for service catalog changes — re-call search after any state-changing operation (e.g. after create_service_from_template returns active).
Sub-actions, by category:
| Category | Action | Purpose |
|---|---|---|
| Service instantiation | create_service_from_template |
Create a service instance from a template. Params: template, name, scope (user/org), on_behalf_of?. Returns OAuth start URL, secret-provide URL, or active immediately. |
status |
Poll a pending service. Params: service. Returns the service's lifecycle state (pending_credentials, active, error, archived) along with flow_kind and flow_url when pending. See §9 Service Lifecycle States. |
|
retry_credentials |
Re-issue a fresh credential flow URL for a pending_credentials or error service. Invalidates any previous URL on the same row. |
|
| Secret management | list_secrets |
List secret names + version metadata visible to caller (never values). |
request_secret |
Request a new secret value from a user. Returns a signed /secrets/provide/req_... URL. |
|
rotate_secret |
Rotate a secret on an active service. Params: service, slot. Returns a signed /secrets/provide/... URL for the user to paste a new value. The service stays active throughout — rotation is a secret_version++ operation, never a state change (§6, §9). |
|
| Sub-identities | create_subagent |
Create a sub-agent under the calling agent. Params: name, inherit_permissions?, ttl?. Returns API key once. |
| Auth introspection | whoami |
Return the calling identity's SPIFFE path, depth, owner-user, group memberships. |
Many flows are asynchronous from the agent's perspective: OAuth callback, secret provisioning, approval resolution. Overslash supports three transports for the same underlying events. Callers pick whichever fits their environment:
| Transport | Best for | Mechanism |
|---|---|---|
| Polling | Simple agents, no infra | Re-call the relevant GET endpoint (/v1/services/{id}, /v1/approvals/{id}). Idempotent. |
| SSE | Agents that can hold an HTTP connection | GET /v1/events/stream?topics=... opens a Server-Sent Events stream. Connection has a fixed 30-second timeout — clients reconnect with Last-Event-ID to resume. The 30s ceiling keeps idle connections cheap, plays nicely with proxies, and forces clients to handle reconnection cleanly. Topics are scoped to the authenticated identity (e.g., approvals, services). |
| Webhooks | Platform integrations with their own infra | Configure a webhook endpoint per identity or per org; Overslash POSTs events with HMAC signature. |
The same event payload is delivered regardless of transport. Agents may use any combination — e.g., SSE for liveness during a foreground task, webhooks for background events, polling as a fallback.
Webhook envelope. Webhook bodies are always wrapped in a stable envelope so receivers can deserialize uniformly:
{
"id": "<delivery uuid>",
"type": "approval.resolved",
"created_at": "2026-05-05T12:34:56.789Z",
"data": { /* per-event payload */ }
}The id and created_at are stable across retries, so receivers can dedupe by id and reject stale replays by created_at. Routing headers mirror the envelope: X-Overslash-Event (event name), X-Overslash-Delivery (delivery id). X-Overslash-Signature: sha256=<hex> is HMAC-SHA256 over the raw body bytes (the envelope JSON), keyed with the subscription secret.
When notifications.managed_by_platform is set (§5), Overslash's user-facing notifications (bell, email, 1-minute delayed webhook) are suppressed — but the event-stream transports above still fire normally, because the platform is the consumer.
Web UI for non-API interactions. Built with SvelteKit + TypeScript.
Two delivery modes. In cloud mode the dashboard is hosted on Vercel with full SvelteKit (SSR allowed) and proxies API/auth/health/public/SKILL.md paths back to the API origin via vercel.json rewrites. In self-hosted mode the operator runs overslash web, which boots the same Axum app and serves the dashboard same-origin from embedded static assets (built with @sveltejs/adapter-static, embedded into the binary at compile time behind the embed-dashboard Cargo feature). Same-origin removes the cross-origin cookie and CORS complexity that Vercel rewrites paper over in cloud mode — the same router serves /v1/*, /auth/*, /health/*, /public/*, /SKILL.md, and falls back to the SPA for everything else (with index.html for unknown paths to support client-side routing). Cloud and self-hosted ship from the same codebase; the only difference is which Cargo feature is enabled and which subcommand is invoked.
- Agents (default landing view) — tree view of the identity hierarchy rooted at the logged-in user. The user node is immutable (cannot be deleted, renamed, or reparented). Agent creation does not offer a Kind selector — all created identities are agents, and parentage determines hierarchy position. Inline management: create, edit, delete agents.
- User profile — authenticated user info, API keys, settings
- Services — browse templates, create/manage service instances, connect credentials
- Developer connection tool (API Explorer) — interactive API explorer for connected services. Select a service, pick a defined action or make a custom request, fill in parameters, and call. Similar to Swagger UI or Postman but integrated with Overslash auth. Available actions adapt to the user's group grants (defined actions, HTTP verbs, or raw HTTP). Always calls as the logged-in user's own identity — no agent impersonation. Actions are logged in the audit trail under the user. Can be hidden via org setting.
- Audit log — searchable, filterable log of all actions, approvals, and secret accesses. Filterable by identity, service, time range, event type.
Templates (browse/create/import), Services (org-level instances, group assignment), Webhooks, Settings.
My Services (instances + credentials), My Secrets (names + versions), Approvals (pending, one-click resolve with expiry picker), My Agents (permission management).
Overslash provides built-in standalone pages for common user interactions. These serve two purposes: (1) direct use by unplatformed agents (e.g., agents connecting to Overslash without a platform intermediary), and (2) a zero-effort integration path for platforms that don't want to build their own UI for these flows.
Platforms can always build fully white-label equivalents using the same REST API these pages consume. The API exposes all the data needed: approval details with suggested tiers, secret request metadata, OAuth consent payloads. The built-in pages are a convenience, not a requirement.
-
Approval resolution (
/approvals/apr_...) — requires login. Shows approval details and specificity picker. See §5 Trust Model. -
Secret request (
/secrets/provide/req_...?token=jwt) — no login required by default for the user landing on the page (signed URL). Secure input field for secret provisioning. Safe because providing a secret doesn't grant the agent authority. One page, two contexts: this URL is used both for (a) mid-execution secret requests when an agent callsoverslash_auth.request_secretand (b) initial bootstrap of a secret-based service when an agent callscreate_service_from_templateagainst a secret-based template (§9 Programmatic Service Creation). Both contexts share the same security properties — the signed token scopes the page to a single secret slot on a single identity.The API calls that generate these URLs always require an authenticated identity — typically an enrolled agent acting
on_behalf_ofits owner-user, or a user acting through the dashboard. There is no path for an unenrolled or anonymous caller to issue a secret-provide URL. The "no login" property describes only the user-facing redemption step, not the issuance step.The signed URL is anonymous by default — the JWT in the URL is the sole capability gate. Two strictly additive enhancements raise the bar for orgs that need a named human on every secret provision:
-
Opportunistic session binding. If the visitor's browser already holds a valid
oss_sessioncookie for the same org as the request, the page captures that identity on thesecret_versions.provisioned_by_user_idcolumn and on the audit-logdetailJSON (user_signed: true,provisioned_by_user_id: <uuid>). The visitor does not have to log in — but if they're already logged in, we record who they were. The signed URL remains the capability gate; the session is purely an identity attestation. When both are present, the session is the primary identity for the audit row (identity_idon the audit entry is the session user, not the target identity). -
Required user session (org setting). Org admins can set
allow_unsigned_secret_provide = falseviaPATCH /v1/orgs/{id}/secret-request-settings. New secret requests minted while the toggle is off are stampedrequire_user_session = trueat mint time and must be redeemed by a visitor with a same-org session — anonymous submission is rejected with401 user_session_required. The toggle is forward-only: outstanding URLs minted before the flip continue to honor the policy they were issued under, so flipping the toggle never breaks in-flight requests.
Cross-tenant sessions are ignored (treated as anonymous). A session in org A cannot be used to provision a secret in org B, regardless of token validity — the standalone page silently drops the cookie in that case.
-
-
OAuth consent (
/oauth/consent?request_id=...) — requires login. MCP-client enrollment approval with name editing, parent placement, andinherit_permissions/group toggles. See §4 Agent Enrollment. -
SKILL.md (
/SKILL.md) — unauthenticated. Agent-facing enrollment instructions, served from the repo-rootSKILL.mdfile.
Every action execution, approval resolution, secret access, and connection change is logged with the full identity chain. Queryable by identity, service, time range, and event type.
For approvals and audit rows to be useful for human review, resolvers need to know what an action is about to do — not just that an HTTP request is pending. Templates can declare two opt-in extensions on any HTTP action to control how a resolved request is surfaced:
x-overslash-disclose— a labeled list of jq filters. Each filter runs at approval-create time (and again at call-success audit-write time) against a structured projection of the resolved request. Results land onapprovals.disclosed_fieldsand onaudit_log.detail.disclosed, rendered in the dashboard as a prominent "Summary" block above the raw-payload disclosure.x-overslash-redact— a list of dotted paths into the same projection. Matched values are replaced with the sentinel"[REDACTED]"before the projection is persisted asapprovals.action_detail. Redaction defends the raw-payload blob from leaking template-declared sensitive fields; it does not affect disclosure extraction (which runs first).
Each disclose filter runs against this projection of the resolved request:
{
"method": "POST",
"url": "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
"params": { "userId": "me" },
"body": { "raw": "VG86IGFsaWNlQGV4YW1wbGUuY29tCg..." },
"resolved": { "userId": "alice@example.com" }
}bodyis parsed as JSON when the outbound request'sContent-Typeis a JSON media type (application/json,application/*+json); otherwise it's carried as the raw string.paramsis the post-resolution parameter map — every arg the agent passed, regardless of whether it was bound to the URL path, the query string, or the body.resolvedis the display-name map produced by the template'sresolveparam declarations (param name → human-readable string, e.g. a DrivefileId→ the file's name). Only params whose lookup succeeded appear, so filters should fall back explicitly:.resolved.fileId // .params.fileId. Resolution runs at most once per call, at resolve time, and the map rides in the request metadata through execution — a delete action's audit-write disclosure still names the object even though it no longer exists upstream. An answer may be served from a bounded-TTL cache keyed on the org, the credential principal, the concrete target and the projection, so "once per call" is a ceiling on upstream traffic, not a guarantee of it (D64). MCP-runtime actions carry the same key in their own projection ({runtime, tool, arguments, resolved, service, action}), so the idiom there reads.resolved.recipient // .arguments.recipient. Platform-runtime actions have noresolvedkey ({runtime, action, params, service}): they make no outgoing call for a resolver to ride on.
paths:
/gmail/v1/users/{userId}/messages/send:
post:
operationId: send_message
disclose:
- label: To
filter: '.body.raw | gsub("-"; "+") | gsub("_"; "/") | @base64d | capture("(?im)^To:\\s*(?<v>[^\\r\\n]+)").v'
- label: Subject
filter: '.body.raw | gsub("-"; "+") | gsub("_"; "/") | @base64d | capture("(?im)^Subject:\\s*(?<v>[^\\r\\n]+)").v'
- label: Body
filter: '.body.raw | gsub("-"; "+") | gsub("_"; "/") | @base64d | split("\r\n\r\n")[1:] | join("\r\n\r\n")'
max_chars: 2000
redact:
- body.rawA resolver-backed declaration — the disclosed field prefers the human-readable name and degrades to the opaque ID when the lookup failed:
paths:
/drive/v3/files/{fileId}:
parameters:
- name: fileId
in: path
required: true
schema: { type: string }
resolve:
get: /drive/v3/files/{fileId}
pick: name
delete:
operationId: delete_file
summary: "Delete file {fileId}"
risk: delete
disclose:
- label: File
filter: '.resolved.fileId // .params.fileId'Unprefixed disclose: / redact: aliases normalize to x-overslash-disclose / x-overslash-redact like the other operation-level extensions. jq syntax is validated at template register / promote time; a malformed filter rejects the template with a disclose_invalid_jq issue.
POST /v1/actions/call takes deliver: "inline" | "url", default inline, also exposed on the overslash_call / overslash_read MCP tools. With deliver: "url" the response body is replaced by a descriptor and the bytes move out of band:
{ "download_url": "https://api.overslash.com/v1/downloads/<token>",
"expires_at": "2026-08-04T12:15:00Z",
"mime": "video/mp4", "size_bytes": 41943040, "filename": "clip.mp4" }GET /v1/downloads/{token} is unauthenticated — the token is the capability, the way a presigned URL is. It is 256 bits of randomness stored only as a SHA-256 hash, expires after DOWNLOAD_TOKEN_TTL_SECS (default 900), and stays redeemable until then so a resumed or retried transfer works. Redemption re-resolves the upstream credential from the vault and re-checks the identity, then streams the bytes through with content-type / content-length / content-disposition / etag / last-modified / cache-control forwarded. MAX_RESPONSE_BODY_BYTES does not apply, exactly as with prefer_stream. Unknown and expired tokens both return a bare 404.
filter is reachable from every surface including MCP, where both call tools declare it as a bare jq string and the dispatcher lifts it into the wire's {lang, expr}. It applies on all three runtimes (HTTP, MCP, platform). It narrows what the caller receives, not what the upstream sends: the size cap fires while the body is still arriving, so an oversized response fails before the filter runs — pair it with the action's own paging parameters. Combining deliver: "url" with filter or prefer_stream is a 400, as is passing a credential in an inline headers entry on a raw-HTTP call — name it via secrets instead, and it is resolved at fetch time. Mint writes an action.deferred audit row (HTTP runtime only; MCP already wrote action.executed); redemption writes action.downloaded.
An HTTP action needs no declaration — it is its own download, so the token captures the resolved request. An MCP tool returns a descriptor pointing at the bytes, so it declares where:
- name: download_media
risk: read
download:
url: .structured.media_path # required
mime: .structured.mime # optional metadata
size: .structured.size
filename: .structured.filename
auth: inherit # or `none` for a pre-signed URLFilters are jq over the same {runtime, tool, structured, content, is_error} envelope the disclose filters see. The resolved location must be same-origin with the MCP server's own URL — a relative path is joined against it, an absolute URL elsewhere is refused. The deferred fetch attaches that instance's credential, so without this a compromised MCP server could name any host and be handed the bearer. OAuth-authenticated services are not supported yet: their credential is minted live and deliberately not persistable. The gate reads oauth_injected rather than the presence of an Authorization header, since a query-param token injection resolves OAuth with no header to check.
A cap failure mints one for you. When a buffered call exceeds MAX_RESPONSE_BODY_BYTES, the token for that same request is minted at the point of failure and returned on the 502 as download_url + expires_at — deliver: "url" on the HTTP runtime never needs the body, so the failure already holds everything the retry needs. The status stays 502: nothing silently succeeds, and a caller that ignores the fields sees the same error it saw before. Minting is best-effort — the refusals above (OAuth-injected services, inline raw-HTTP credentials) and any other failure leave the fields absent, and the hint falls back to naming deliver: "url", plus prefer_stream: true for the callers who can send it. That gives the hint three forms in all, under one rule: never name a recovery the caller cannot use. A caller that passed a filter still gets a URL, and the hint says the URL serves the unfiltered body, since the filter never ran. The audit row records cause: "response_too_large" to distinguish it from a caller that asked.
This does not extend to the MCP runtime or to approval replay. An MCP tool's download URL is derived by running the action's x-overslash-download filters over the tool result — which is precisely the thing that was too large — and replay has no resolved mint context, so a gated deliver: "url" still hits the buffered cap.
See D51 and D57.
{ "label": "To", "value": "alice@example.com", "error": null, "truncated": false }error carries a fixed classification of a per-filter failure — filter runtime error, optionally qualified with jq's own error kind ((cannot index), (cannot calculate), (cannot use)), plus filter produced more than N values for the output cap. One filter's failure never poisons the rest of the summary.
The engine's own message is deliberately never carried. jq embeds the operands it choked on directly in its error text, and disclosure filters run against the un-redacted projection (see below) — so propagating that text would put a redacted value onto approvals.disclosed_fields and audit_log.detail.disclosed, the two places redaction exists to keep it out of. This mirrors the rule the credential-template renderer has always enforced (services/credential_template.rs). Response filters (§ filter) are the deliberate exception: their operand is the upstream body, which the caller already receives on result.body.
truncated is set when the value hit the per-field max_chars clamp or the 10 KB hard ceiling.
All of an action's filters run in one spawn_blocking task with these limits:
- Per-filter timeout —
filter_timeout_ms(same setting that gates response filters). - Batch timeout —
n × filter_timeout_ms, capped at an absolute 30 s wall-clock ceiling. Scales linearly with field count so legitimate multi-field templates aren't silently degraded, while the absolute ceiling defends against pathological templates. - Output values cap — 10 000 per filter (matches
response_filter). Disclosure expects exactly one; excess values settruncated: trueon the field and take the first. - Per-value size cap — 10 KB, applied on top of
max_chars. - Projection size cap — 1 MB (safety ceiling, one order of magnitude above the
action_detailproduct limit).
Templates are authored by org ops (three-tier registry: global / org / user). A template author who chooses not to redact a sensitive path takes responsibility for that call — redaction is declarative, not heuristic. The disclose jq engine can read redacted-target paths (extraction runs on the un-redacted projection); if an author surfaces a token via a filter, they're doing so deliberately.
That responsibility covers what an author writes, not what the engine says when a filter breaks. Extraction has to see the un-redacted projection — the shipped Gmail template redacts body.raw and discloses To/Subject/Body extracted from body.raw — so a filter that assumed the wrong shape (.body.card_number.last4 against a plain string) would otherwise hand back the very value the same template redacted, with nothing in the filter text for a reviewer to catch. Hence the fixed error strings above: a shape mismatch can never leak what an author did redact.
Overslash enforces per-identity rate limiting to prevent abuse, runaway agents, and resource exhaustion. This is not upstream API rate limiting (which remains a non-goal per §2) — it limits requests to Overslash itself.
Every authenticated request checks two counters, both must pass:
- User bucket — keyed on the owning User. All agents and sub-agents under a User share this budget. Prevents malicious or forking agents from circumventing limits by spawning sub-identities.
- Identity cap (optional) — keyed on the specific identity (agent/sub-agent). A tighter ceiling that prevents a single misconfigured agent from consuming the entire User budget.
The User bucket limit is resolved in priority order:
- Per-user override (scope
user) - Group default — most permissive across the user's groups (scope
group) - Org-wide default (scope
org) - System fallback (
DEFAULT_RATE_LIMITenv var, default 1000 req/min)
Identity caps are per-identity only — no inheritance.
Configured by org admins via PUT /GET /DELETE /v1/rate-limits.
- Algorithm: Fixed window counter.
- Headers on all responses:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(reflecting the User bucket). - 429 Too Many Requests with
Retry-Afterheader and JSON body when exceeded. - Storage: Redis/Valkey if available (distributed, accurate across instances); in-memory
DashMapfallback (single-instance, no external dependency). - Fail-open: If Redis becomes unavailable at runtime, requests are allowed through (logged as warning).
- Health endpoint (
/health) and build stamp (GET /v1/version) are exempt from rate limiting. - Health vs. readiness:
/healthis liveness — it reports database reachability in the body (db,db_latency_ms/db_error) but always returns 200, because it backs the Cloud Run startup and liveness probes and a 503 there would restart containers during a database outage./readyruns the same boundedSELECT 1and returns 503 when Postgres is unreachable; it is the endpoint to point a load balancer or alerting monitor at. - Build stamp: both
/healthand/readyalso reportversion(release, or the crate version for unreleased builds) andcommit(full git SHA, orunknown).GET /v1/versionreturns the same two values pluscommit_short, without the database probe — it is unauthenticated for the same reason/healthis, and is what the dashboard renders in its sidebar footer. The SHA is baked in at compile time (OVERSLASH_GIT_SHA, supplied by Cloud Build as$COMMIT_SHAor discovered viagitfor local builds).
Overslash will be released as open source (Apache 2.0 or similar). It has no platform-specific logic. The global service registry is community-maintained via PRs.
Callers (like Overfolder) build their own channel-specific integrations (Telegram approval buttons, etc.) on top of Overslash's REST API and approval URLs.