name: add-keys description: Explains Corsair's key management model. Read this before running any plugin key setup skill. Understand the two-level key system before writing any setup scripts.
Corsair Key Management
Credentials
When you need a credential from the user, give them these three options and let them choose:
Option 1 — Paste into chat They can just paste the value directly into the conversation. Use it inline in the script. This is fine if they don't mind sharing it in the chat window.
Option 2 — Run a shell command
Run pwd to get the absolute project path — call it $DIR. Then show them:
echo 'SOME_API_KEY=YOUR_KEY_HERE' >> $DIR/.env
In the setup script, read from process.env:
const API_KEY = process.env.SOME_API_KEY!;
After the script runs, delete the temp line from .env. The sed command must always include the absolute path to the file:
sed -i '' '/^SOME_API_KEY=/d' $DIR/.env
Run one sed command per env var added.
Option 3 — Edit .env directly
Run pwd to get the absolute project path and output the full path to the .env file so the user can ⌘-click it to open in their editor:
/absolute/path/to/project/.env
Tell them to add the following line and save:
SOME_API_KEY=YOUR_KEY_HERE
Then the script reads it from process.env the same way as Option 2, and you clean it up with sed afterward.
Note: If you're asking for a webhook signature for an integration, log the webhook URL so the user can reference it when setting up the webhook. The webhook URL is stored in .env as WEBHOOK_URL.
Storage
Keys are never stored in .env long-term. The only things that permanently belong in .env are:
CORSAIR_KEK— the Key Encryption Key (master key for envelope encryption)OPENAI_API_KEYorANTHROPIC_API_KEY— the AI provider key
Everything else (Slack tokens, Linear keys, Google OAuth credentials, etc.) is stored encrypted in the database. This means:
- No agent restart needed when setting or updating a key
- Keys are encrypted at rest using envelope encryption (DEK wrapped by KEK)
- Credentials never appear in environment variables or config files
Two-level key model
Every plugin has two key managers:
Integration level — corsair.keys.[plugin]
Provider/app credentials shared across all users. For OAuth2 plugins this holds the OAuth client credentials. For API key plugins these fields are empty (no shared credentials).
| Auth type | Integration fields |
|---|---|
api_key |
(none) |
bot_token |
(none) |
oauth_2 |
client_id, client_secret, redirect_url |
Account level — corsair.[plugin].keys
Per-user credentials. For single-tenant setups the tenant ID is always 'default'.
| Auth type | Account fields |
|---|---|
api_key |
api_key, webhook_signature |
bot_token |
bot_token, webhook_signature |
oauth_2 |
access_token, refresh_token, expires_at, scope, webhook_signature |
Each level has auto-generated get_<field>() and set_<field>() methods.
DB rows required
Before calling any set_* or issue_new_dek() method, two rows must exist:
- A row in
corsair_integrationswithname = '<plugin-id>' - A row in
corsair_accountswithtenant_id = 'default'and the matchingintegration_id
Then each level needs its DEK initialised via issue_new_dek() before any field can be encrypted.
Script pattern
All key setup is done by writing a TypeScript script, running it once inside the container, then deleting it. The script handles both first-time setup and updates.
Template for api_key plugins:
import 'dotenv/config';
import { and, eq } from 'drizzle-orm';
import { corsair } from '../server/corsair';
import { db } from '../server/db';
import { corsairAccounts, corsairIntegrations } from '../server/db/schema';
const PLUGIN = 'slack'; // plugin id
const TENANT_ID = 'default';
// ── credentials (fill these in) ───────────────────────────────────────────────
const API_KEY = 'xoxb-...';
const WEBHOOK_SIGNATURE = '...';
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
// 1. Ensure integration row exists
let [integration] = await db
.select()
.from(corsairIntegrations)
.where(eq(corsairIntegrations.name, PLUGIN));
if (!integration) {
[integration] = await db
.insert(corsairIntegrations)
.values({ id: crypto.randomUUID(), name: PLUGIN })
.returning();
console.log(`✓ Created integration: ${PLUGIN}`);
}
// 2. Issue (or rotate) integration-level DEK
await corsair.keys.slack.issue_new_dek();
console.log('✓ Integration DEK ready');
// 3. Ensure account row exists
const [existing] = await db
.select()
.from(corsairAccounts)
.where(
and(
eq(corsairAccounts.tenantId, TENANT_ID),
eq(corsairAccounts.integrationId, integration!.id),
),
);
if (!existing) {
await db.insert(corsairAccounts).values({
id: crypto.randomUUID(),
tenantId: TENANT_ID,
integrationId: integration!.id,
});
console.log('✓ Created account');
}
// 4. Issue (or rotate) account-level DEK
await corsair.slack.keys.issue_new_dek();
console.log('✓ Account DEK ready');
// 5. Store credentials
await corsair.slack.keys.set_api_key(API_KEY);
await corsair.slack.keys.set_webhook_signature(WEBHOOK_SIGNATURE);
// 6. Verify
const stored = await corsair.slack.keys.get_api_key();
console.log(`✓ Done. Key starts with: ${stored?.slice(0, 8)}...`);
process.exit(0);
}
main().catch((e) => { console.error(e); process.exit(1); });
Template for oauth_2 plugins (e.g. Google) — needs both integration AND account credentials:
// Integration level (provider/app credentials)
await corsair.keys.googlecalendar.issue_new_dek();
await corsair.keys.googlecalendar.set_client_id(CLIENT_ID);
await corsair.keys.googlecalendar.set_client_secret(CLIENT_SECRET);
await corsair.keys.googlecalendar.set_redirect_url('http://localhost:3000/oauth/callback');
// Account level (user tokens)
await corsair.googlecalendar.keys.issue_new_dek();
await corsair.googlecalendar.keys.set_refresh_token(REFRESH_TOKEN);
Running a script
Write the script to scripts/setup-<plugin>.ts, run it, then delete it:
docker compose exec agent pnpm tsx scripts/setup-<plugin>.ts
No restart needed. The running agent reads keys from the DB on every request.
Always delete the script after it runs — it contains credentials in plaintext:
rm scripts/setup-<plugin>.ts
Plugin sub-skills
Each plugin has its own skill with the exact script to run:
| Plugin | Auth type | Skill |
|---|---|---|
| Slack | api_key |
/add-keys/slack |
| Linear | api_key |
/add-keys/linear |
| Resend | api_key |
/add-keys/resend |
| Discord | api_key |
/add-keys/discord |
| Google Calendar | oauth_2 |
/add-keys/google |
| Google Drive | oauth_2 |
/add-keys/google (shares credentials with Calendar) |