@keychains/machine-sdk
v0.0.6
Published
Machine SDK for Keychains.dev — register machines, create permissions, mint tokens, and make proxy calls
Maintainers
Readme
@keychains/machine-sdk
Full-lifecycle SDK for making authenticated API calls through the Keychains.dev proxy from machines you control — servers, dev laptops, CI runners, or any environment with persistent file storage.
The Machine SDK handles everything automatically: machine registration, permission management, token minting, and proxied API calls. Credentials are stored in ~/.keychains/ and reused across runs.
Running in a serverless or stateless environment? Use the lighter
@keychains/client-sdkinstead. It only requires a pre-minted permission token and performs proxied fetch — no file system needed.
Quickstart
1. Install
npm install @keychains/machine-sdk2. Use keychainsFetch as a drop-in replacement for fetch
The only difference? You can replace any credential with a template variable.
import { keychainsFetch } from '@keychains/machine-sdk';
// Gmail — get last 10 emails from my inbox
const res = await keychainsFetch(
'https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10',
{
headers: {
Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}',
},
},
);
const emails = await res.json();
console.log(emails);On the first call, the SDK automatically:
- Registers the machine if no credentials exist in
~/.keychains/ - Creates a wildcard permission (or reuses an existing one)
- Mints a permission token, caches it, and makes the proxied request
You can customize the auto-setup via the keychains property (stripped before passing to fetch):
// Custom machine name and scoped permission
const res = await keychainsFetch('https://api.lifx.com/v1/lights/all', {
headers: { Authorization: 'Bearer {{LIFX_PERSONAL_ACCESS_TOKEN}}' },
keychains: {
machineName: 'my-home-server',
permissionName: 'LIFX Controller',
scopes: ['lifx::key::LIFX_PERSONAL_ACCESS_TOKEN'],
},
});
// Multi-account: target a specific account by identifier
const res2 = await keychainsFetch('https://api.github.com/user', {
headers: { Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}' },
keychains: {
account: '[email protected]',
},
});Template Variables
How to write them
Template variables use the {{VARIABLE_NAME}} syntax. The variable name tells the proxy which type of credential to inject:
| Prefix | Type | Supported Variables |
|--------|------|---------------------|
| OAUTH2_ | OAuth 2.0 token | {{OAUTH2_ACCESS_TOKEN}}, {{OAUTH2_REFRESH_TOKEN}} |
| OAUTH1_ | OAuth 1.0 token | {{OAUTH1_ACCESS_TOKEN}}, {{OAUTH1_REQUEST_TOKEN}} |
| Anything else | API key | {{LIFX_PERSONAL_ACCESS_TOKEN}}, {{OPENAI_API_KEY}}, etc. |
Where to put them
Place them exactly where you'd normally put the real credential — headers, body, or query parameters:
// In a header (most common)
await keychainsFetch('https://api.lifx.com/v1/lights/all', {
headers: { Authorization: 'Bearer {{LIFX_PERSONAL_ACCESS_TOKEN}}' },
});
// In the request body
await keychainsFetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel: '#general', text: 'Hello!' }),
});
// In query parameters
await keychainsFetch(
'https://api.example.com/data?api_key={{MY_API_KEY}}&format=json',
);What Happens Next
When you call keychainsFetch:
- Machine registration — if
~/.keychains/credentials.jsondoesn't exist, generates an Ed25519 keypair and registers with the Keychains server - Permission resolution — finds an existing active permission or creates a new one (wildcard by default, scoped if
scopesis provided) - Token minting — authenticates via SSH challenge-response and mints a short-lived permission token (cached and auto-refreshed)
- URL rewriting —
https://api.lifx.com/v1/lights/allbecomeshttps://keychains.dev/api.lifx.com/v1/lights/all - Credential resolution — the proxy replaces
{{LIFX_PERSONAL_ACCESS_TOKEN}}with the real API key from the user's vault - Request forwarding — the proxy forwards the request to the upstream API with real credentials injected
- Response passthrough — the upstream response is returned to you as-is
Handling missing approvals
With wildcard permissions, users approve scopes on demand. The first time your code hits a new API, the user may not have approved it yet. When that happens, keychainsFetch throws a KeychainsError with an approvalUrl — share it with the user so they can grant access:
import { keychainsFetch, KeychainsError } from '@keychains/machine-sdk';
try {
const res = await keychainsFetch('https://api.github.com/user', {
headers: { Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}' },
});
console.log(await res.json());
} catch (err) {
if (err instanceof KeychainsError && err.approvalUrl) {
// The user hasn't approved GitHub yet — show them the link
console.log('Please approve access:', err.approvalUrl);
// Once approved, retry the same call and it will succeed
}
}Security benefits
- Secrets never leave the Keychains.dev servers — your code, logs, and environment stay clean
- Users approve exactly which scopes and APIs an agent can access
- Credentials can only be sent to the APIs of the providers they belong to
- Every proxied request is audited with full traceability
- Permissions can be revoked instantly from the dashboard
User Identity
getUser(config?)
Get the identity of the user linked to this machine.
import { getUser } from '@keychains/machine-sdk';
const user = await getUser();
console.log(user.userId, user.email, user.name);Returns { userId, email?, name?, telegramUsername? }. Throws if the machine is not yet linked to a user account.
Connection Management
List and revoke the user's connected providers (OAuth connections, API keys, etc.):
listConnections(opts?, config?)
List all connections for the linked user. Optionally filter by provider or status.
import { listConnections } from '@keychains/machine-sdk';
// All active connections
const connections = await listConnections();
// Filter by provider
const githubConns = await listConnections({ provider: 'github' });
// Include all statuses
const all = await listConnections({ status: 'all' });Each connection includes id, provider, type (oauth or api_key), status, displayName, scopes, accountIdentifier, and timestamps.
revokeConnection(connectionId, config?)
Revoke (delete) a connection. The server cleans up credentials asynchronously.
import { revokeConnection } from '@keychains/machine-sdk';
await revokeConnection('conn_abc123');Machine Management
These functions handle machine identity and SSH key lifecycle:
register(name, config?)
Register a new machine. Generates an Ed25519 keypair, stores it in ~/.keychains/, and registers the public key with the server.
import { register } from '@keychains/machine-sdk';
const creds = await register('my-build-server');
console.log('Machine ID:', creds.machineId);authenticate(config?)
Authenticate via SSH challenge-response. Returns a short-lived machine JWT.
import { authenticate } from '@keychains/machine-sdk';
const { token, userId, expiresAt } = await authenticate();rotateKeys(config?)
Rotate the SSH keypair — generates new keys and re-registers with the server. Old keys are backed up.
import { rotateKeys } from '@keychains/machine-sdk';
const newCreds = await rotateKeys();Permission Management
Permissions control which APIs and scopes a machine can access:
createPermissionRequest(opts, config?)
Create a new permission. Wildcard permissions let scopes be approved on demand; scoped permissions require pre-defined scopes.
import { createPermissionRequest } from '@keychains/machine-sdk';
// Wildcard (scopes approved dynamically)
const { permission, approvalUrl } = await createPermissionRequest({
name: 'my-agent',
wildcard: true,
allowDelegation: true,
});
// Scoped (pre-defined scopes)
const { permission: scoped } = await createPermissionRequest({
name: 'github-reader',
requestedScopes: ['github::repo', 'github::read:user'],
});listPermissions(config?)
List all permissions for this machine.
import { listPermissions } from '@keychains/machine-sdk';
const permissions = await listPermissions();mintPermissionToken(requestId, ttlSeconds?, config?)
Mint a short-lived permission token for use with the proxy or the Client SDK.
import { mintPermissionToken } from '@keychains/machine-sdk';
const { token, expiresIn } = await mintPermissionToken(permission.id, 600);checkScopes(requestId, scopes, config?)
Check if specific scopes are approved for a permission.
import { checkScopes } from '@keychains/machine-sdk';
const { allowed, missing, approvalUrl } = await checkScopes(
permission.id,
['github::repo'],
);revokePermission(requestId, config?)
Revoke a permission permanently.
import { revokePermission } from '@keychains/machine-sdk';
await revokePermission(permission.id);Delegation
Delegates allow a machine to grant sub-permissions to remote systems (e.g., a CI runner, a cloud function):
createDelegate(permissionRequestId, opts?, config?)
Create a delegate identity with its own Ed25519 keypair. Ship the private key to the remote system.
import { createDelegate } from '@keychains/machine-sdk';
const { delegate, privateKey } = await createDelegate(permission.id, {
mode: 'wildcard',
allowDelegation: false,
});
// Ship `privateKey` and `delegate.delegateId` to the remote systemmintDelegateToken(delegateId, privateKey, ttlSeconds?, config?)
Mint a permission token using a delegate's private key (runs on the remote system).
import { mintDelegateToken } from '@keychains/machine-sdk';
const { token, expiresIn } = await mintDelegateToken(
delegateId,
privateKey,
300,
);Lower-level Proxy
If you manage machine registration and permissions yourself, use proxyFetch directly:
import { proxyFetch } from '@keychains/machine-sdk';
const res = await proxyFetch('https://api.github.com/user/repos', {
permissionRequestId: 'pr_abc123',
method: 'GET',
headers: { Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}' },
});
// Multi-account: target a specific account
const res2 = await proxyFetch('https://api.github.com/user', {
permissionRequestId: 'pr_abc123',
account: '[email protected]',
headers: { Authorization: 'Bearer {{OAUTH2_ACCESS_TOKEN}}' },
});proxyFetch handles token minting, caching, auto-refresh on 401, and throws KeychainsError on 403. The optional account parameter targets a specific account when the user has multiple accounts connected for the same provider.
Environment Variables
| Variable | Description |
|----------|-------------|
| KEYCHAINS_PERMISSION_ID | Permission ID for auto-resolution (used by keychainsFetch) |
Bug Reports & Feedback
Found a bug or have a suggestion? Submit it straight from your terminal:
# Report a bug
keychains feedback "The proxy returns 502 on large POST bodies"
# Send feedback
keychains feedback --type feedback "Love the wildcard permissions!"
# With more detail
keychains feedback --type bug --title "502 on large POST" --description "When sending >1MB body to Slack API..." --contact [email protected]The keychains feedback command (alias: keychains bug) sends your report directly to the engineering team.
More Info
Let's meet on keychains.dev!
