Secrets
Secrets are encrypted credentials stored in Supabase Vault and scoped to a house. They exist independently of environments — you can store a GITHUB_TOKEN the moment you join a house, before any environment is configured.
At dispatch, environments declare which secrets they need via bindings. The system resolves bound names against the house’s secret store, decrypts via Vault, and injects the values into the sandbox as environment variables.
Data model
Every secret has a house_id (the house it belongs to) and an author_id (who created it). The unique constraint is (house_id, author_id, name) — two users can each have their own GITHUB_TOKEN in the same house.
The shared flag (default false) opts a secret into house-wide visibility. When shared, other house members can resolve it at dispatch. A partial unique index enforces that shared names are unique per house — two users can’t both publish a shared GITHUB_TOKEN.
The audience column classifies secrets as environment (injected as env vars) or capability (reserved for future tool/resource routing). Only environment-audience secrets resolve today.
Schema source: SecretRowSchema in packages/core/schemas/secret.ts. The encrypted value lives in vault.secrets, referenced by vault_secret_id. The secrets table stores only metadata — encrypted values never appear in Electric SQL sync or the mutations audit log.
Resolution
Resolution happens at dispatch time via the resolve_secrets_for_environment Postgres function. The precedence rule is simple: the dispatching user’s own secret wins over a shared house secret with the same name. If no match exists for a required binding, dispatch fails with a clear error naming the missing secret.
The function runs with service-role privileges (bypasses RLS) because it needs Vault access. The dispatch endpoint is responsible for verifying the caller’s identity before invoking it.
Environment bindings
Environments don’t hold secret values — they hold a list of names. Each binding in secret_bindings carries a name and an optional required flag (defaults to true).
{ "secret_bindings": [ { "name": "GITHUB_TOKEN" }, { "name": "NICE_TO_HAVE_KEY", "required": false } ]}One secret, many environments. Rotate once, every environment that binds it gets the new value on the next dispatch.
At dispatch, missing required bindings fail the run. Missing optional bindings produce a warning surfaced on the run record’s environment_snapshot and in the dispatch response.
Schema source: SecretBindingSchema in packages/core/schemas/record-content.ts.
Dispatch integration
When a run specifies --env, dispatch:
- Reads
secret_bindingsfrom the environment content. - Calls
resolve_secrets_for_environment(environment_id, agent_id). - Checks resolved secrets against bindings — fails on missing required, warns on missing optional.
- Writes an
environment_snapshotonto the run record (environment ID, resolved names, missing optional names — never actual values). - Injects resolved secrets into the sandbox as environment variables.
Without --env, dispatch falls back to house-wide resolution via resolve_secrets_for_scope, which resolves all environment-audience secrets for the house. This preserves the implicit path where infrastructure secrets like SPRITES_TOKEN flow into sandboxes without requiring an environment. Local .env file injection continues to work for unauthenticated/offline usage.
CLI
All commands require authentication and operate on the active house (arbe house select).
arbe secret list # names, audience, shared status, rotation agearbe secret view <name> # metadata (no value)arbe secret set <name> # create or rotate (reads value from stdin)arbe secret delete <name> # soft-deleteValues are piped via stdin, never passed as arguments:
echo "sk-..." | arbe secret set OPENAI_API_KEYecho "ghp-..." | arbe secret set GITHUB_TOKEN --sharedset is an upsert — if a secret with that name already exists, it rotates the value instead of creating a duplicate. --shared makes the secret visible to other house members. --audience capability classifies it for future tool routing (no functional effect today).
view shows metadata only. The --reveal flag exists but is not yet wired to a decrypt endpoint.
Storage
Encryption uses Supabase Vault (pgsodium AEAD with a Supabase-managed key). vault.decrypted_secrets provides on-the-fly decryption within Postgres — no application-layer crypto needed.
Vault labels use the format name/house_id/author_id to avoid collisions when multiple users store secrets with the same name in the same house.
Soft-delete sets deleted_at on the metadata row and hard-deletes the Vault entry (encrypted values don’t need tombstones). The unique index filters on deleted_at IS NULL, so recreating a deleted secret name works cleanly. A cascade trigger cleans up secrets when a house is soft-deleted.
Visibility vs. dispatch
Being able to dispatch a run with an environment doesn’t imply being able to read secret values. arbe env view work lists bound secret names and metadata (who created it, when it was last rotated) via a join, but never shows plaintext. House admins can delete secrets they don’t own but cannot reveal their values.
API
| Operation | Method | Path |
|---|---|---|
| List secrets for house | GET | /api/secrets?house_id=<uuid> |
| Create secret | POST | /api/secrets |
| Get metadata | GET | /api/secrets/:id |
| Update metadata | PATCH | /api/secrets/:id |
| Rotate value | PUT | /api/secrets/:id/value |
| Delete | DELETE | /api/secrets/:id |
| Resolve (internal) | POST | /api/secrets/resolve |
The resolve endpoint is internal-only, authenticated by bearer token. It’s the only endpoint that returns decrypted values.
Secret names are immutable — PATCH accepts only shared. Renaming would silently break every environment that binds the old name. Delete and recreate instead.