npm package: @keychains/machine-sdk
Description: Machine SDK for Keychains.dev — register machines, create permissions, mint tokens, and make proxy calls
Author: Interagentic Inc.
Latest version: 0.0.6

# @keychains/machine-sdk

Full-lifecycle SDK for making authenticated API calls through the [Keychains.dev](https://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-sdk`](https://www.npmjs.com/package/@keychains/client-sdk) instead. It only requires a pre-minted permission token and performs proxied fetch — no file system needed.

## Quickstart

### 1. Install

```bash
npm install @keychains/machine-sdk
```

### 2. Use `keychainsFetch` as a drop-in replacement for `fetch`

The only difference? You can replace any credential with a template variable.

```typescript
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:
1. **Registers the machine** if no credentials exist in `~/.keychains/`
2. **Creates a wildcard permission** (or reuses an existing one)
3. **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`):

```typescript
// 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: 'work@company.com',
  },
});
```

---

## 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**:

```typescript
// 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`:

1. **Machine registration** — if `~/.keychains/credentials.json` doesn't exist, generates an Ed25519 keypair and registers with the Keychains server
2. **Permission resolution** — finds an existing active permission or creates a new one (wildcard by default, scoped if `scopes` is provided)
3. **Token minting** — authenticates via SSH challenge-response and mints a short-lived permission token (cached and auto-refreshed)
4. **URL rewriting** — `https://api.lifx.com/v1/lights/all` becomes `https://keychains.dev/api.lifx.com/v1/lights/all`
5. **Credential resolution** — the proxy replaces `{{LIFX_PERSONAL_ACCESS_TOKEN}}` with the real API key from the user's vault
6. **Request forwarding** — the proxy forwards the request to the upstream API with real credentials injected
7. **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:

```typescript
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](https://keychains.dev/dashboard)

---

## User Identity

### `getUser(config?)`

Get the identity of the user linked to this machine.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
import { checkScopes } from '@keychains/machine-sdk';

const { allowed, missing, approvalUrl } = await checkScopes(
  permission.id,
  ['github::repo'],
);
```

### `revokePermission(requestId, config?)`

Revoke a permission permanently.

```typescript
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.

```typescript
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 system
```

### `mintDelegateToken(delegateId, privateKey, ttlSeconds?, config?)`

Mint a permission token using a delegate's private key (runs on the remote system).

```typescript
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:

```typescript
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: 'work@company.com',
  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:

```bash
# 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 you@example.com
```

The `keychains feedback` command (alias: `keychains bug`) sends your report directly to the engineering team.

---

## More Info

Let's meet on [keychains.dev](https://keychains.dev)!