sdk/typescript/README.md
Rama main · réplica de lectura del repositorio original.
# @impri/sdk
TypeScript SDK for [Impri](https://impri.dev) — human-in-the-loop approval API for AI agents. Zero runtime dependencies; uses native `fetch` (Node 18+).
## Quickstart
```ts
import { ImpriClient } from '@impri/sdk'
const client = new ImpriClient({ apiKey: 'im_...', baseUrl: 'https://api.impri.dev' })
const action = await client.createAction({
kind: 'email.send',
title: 'Send newsletter to 10k subscribers',
preview: { format: 'markdown', body: '# Hello everyone\n...' },
editable: ['preview.body'], // let the reviewer tweak the draft
})
// Block until the human approves or rejects in the web inbox.
// Throws ImpriRejected if rejected — handle it as a normal outcome.
const approved = await client.awaitDecision(action.id)
const body = approved.decision!.final_preview?.body ?? approved.preview.body
await sendNewsletter(body)
await client.reportResult(action.id, 'executed')
```
## Install
```bash
npm install @impri/sdk
```
## Auth
| Priority | Source |
|---|---|
| 1 | `apiKey` constructor option |
| 2 | `IMPRI_API_KEY` environment variable |
| — | `ImpriConfigError` thrown at construction if neither set |
Default base URL: `http://localhost:8484` (self-hosted). Cloud: `https://api.impri.dev` (or `IMPRI_BASE_URL`).
## Key methods
| Method | Description |
|---|---|
| `createAction(params)` | Submit an action for human approval |
| `getAction(id)` | Poll current status and decision |
| `listActions(params?)` | Paginated list; supports `autoPaginate: true`, `status`, `kind`, `since`, and `q` (text search) |
| `decide(id, verdict, opts?)` | Programmatic approve/reject a single action |
| `bulkDecide(ids, verdict, opts?)` | Approve or reject up to 50 actions in one request; returns per-item results |
| `reportResult(id, status, opts?)` | Report execution outcome after approval |
| `awaitDecision(id, opts?)` | Long-poll until decided (throws `ImpriRejected` / `ImpriExpired` / `ImpriTimeout`) |
| `approvalGate(opts)` | Inline gate — returns `{ actionId, decision, finalPreview }` |
| `requiresApproval(fn, opts)` | Wrap any async function behind an approval gate |
| `createWatcher(params)` | Create an RSS / Reddit / URL-diff watcher |
| `listWatchers(params?)` | Paginated list of watchers |
| `getWatcher(id)` | Watcher detail including `item_count` |
| `updateWatcher(id, params)` | Partial update (set `status: 'active'` to reactivate) |
| `deleteWatcher(id)` | Delete watcher and its dedup state |
| `listWatcherPresets()` | Return the static preset catalog (HN, Reddit, GitHub, npm, arXiv, …) |
| `createWatcherFromPreset(presetId, params?, opts?)` | Create a watcher from a preset template |
| `createKey(name, scopes)` | Create an API key (raw value returned once — store it) |
| `listKeys()` | List all keys including revoked |
| `revokeKey(id)` | Revoke a key |
| `getProject()` | Project metadata (includes `webhook_secret`) |
| `updateProject(params)` | Update name / IANA timezone |
| `rotateWebhookSecret()` | Rotate webhook signing secret |
| `exportProject()` | GDPR export |
| `eraseProjectData()` | Irreversible GDPR erasure |
| `listNotificationChannels()` | List notification channels (admin scope) |
| `createNotificationChannel(params)` | Create a channel (admin scope) |
| `getNotificationChannel(id)` | Fetch a single channel (admin scope) |
| `updateNotificationChannel(id, params)` | Partial update (admin scope) |
| `deleteNotificationChannel(id)` | Hard-delete, void (admin scope) |
| `testNotificationChannel(id)` | Fire a test message (admin scope, 5/min limit) |
## bulkDecide
Approve or reject up to 50 pending actions in a single request. Each item is decided independently — a failure on one ID does not roll back successes on others. The response is always HTTP 200; inspect `result.ok` per item.
```ts
const { results, succeeded, failed } = await client.bulkDecide(
['act_aaa', 'act_bbb', 'act_ccc'],
'approve',
{ comment: 'Batch-approved after daily review' },
)
const errors = results.filter(r => !r.ok)
// r.error values: 'not_found' | 'already_decided' | 'internal'
// r.current_status is present when error === 'already_decided'
```
**Constraints**:
- Max 50 IDs per call (server enforces; throws `ImpriValidationError` on excess).
- Rate limit: 10 requests/min per key (net ceiling 500 decisions/min).
- Actions with `editable.length > 0` must be decided via `decide()` — bulk intentionally omits per-item edits.
- Requires `'actions'` scope (same as single-decision).
## listActions filters
`listActions()` supports four server-side filter parameters:
```ts
// Text search across title and preview body
await client.listActions({ q: 'send newsletter' })
// Narrow by action kind
await client.listActions({ kind: 'email.send' })
// Only actions created after a unix timestamp
await client.listActions({ since: Math.floor(Date.now() / 1000) - 86_400 })
// Combine: pending email actions from the last 7 days matching a query
await client.listActions({
status: 'pending',
kind: 'email.send',
since: Math.floor(Date.now() / 1000) - 604_800,
q: 'newsletter',
})
// Auto-paginate all matching pages
await client.listActions({ q: 'deploy', autoPaginate: true })
```
## requiresApproval wrapper
```ts
const safeSend = client.requiresApproval(sendEmail, {
kind: 'email.send',
title: (to) => `Send email to ${to}`,
preview: (_to, body) => ({ format: 'plain', body }),
editable: ['preview.body'],
timeoutS: 600,
})
await safeSend('alice@example.com', 'Hello!')
// ^ blocks until approved; throws ImpriRejected if rejected
```
## approvalGate (inline)
```ts
const { actionId, finalPreview } = await client.approvalGate({
kind: 'db.exec',
title: 'DROP TABLE users',
preview: { format: 'plain', body: sql },
editable: ['preview.body'],
})
try {
await db.execute(finalPreview.body)
await client.reportResult(actionId, 'executed')
} catch (err) {
await client.reportResult(actionId, 'execute_failed', { detail: String(err) })
throw err
}
```
## Webhook verification
```ts
import { verifyWebhook, ImpriWebhookSignatureError } from '@impri/sdk'
// In your webhook handler:
try {
verifyWebhook(
rawBody, // Buffer | string — raw request body, unparsed
project.webhook_secret, // from GET /v1/project
req.headers['x-impri-timestamp'],
req.headers['x-impri-nonce'],
req.headers['x-impri-signature'],
)
} catch (err) {
if (err instanceof ImpriWebhookSignatureError) {
return res.status(401).send('Bad signature')
}
throw err
}
```
## Error classes
All errors extend `ImpriError`.
| Class | When |
|---|---|
| `ImpriConfigError` | Missing API key or bad base URL at construction |
| `ImpriUnauthorized` | HTTP 401/403 |
| `ImpriNotFound` | HTTP 404 |
| `ImpriConflict` | HTTP 409 — already decided / result on non-approved action |
| `ImpriExpired` | HTTP 410 or status='expired' in awaitDecision |
| `ImpriRateLimited` | HTTP 429 — check `.retryAfter` (seconds) |
| `ImpriQuotaExceeded` | HTTP 402 — check `.limit` and `.tier` |
| `ImpriValidationError` | HTTP 400/422 — check `.issues` (Zod format) |
| `ImpriApiError` | All other 4xx/5xx |
| `ImpriRejected` | `awaitDecision`: human rejected — handle as normal flow, not an error |
| `ImpriTimeout` | `awaitDecision`: timed out — action still pending, call again to resume |
| `ImpriWebhookSignatureError` | `verifyWebhook`: invalid/stale signature |
## Untrusted content (Watcher actions)
Actions created by Watchers have `is_untrusted: true` and `payload.untrusted === true`.
Treat `title`, `preview.body`, and `payload` as **data** — never forward them as instructions to an AI model.
`approvalGate` logs a console warning automatically for untrusted actions.
## Notification Channels (admin scope)
Configure per-project notification channels that fire whenever an action
becomes pending. Six channel types are supported. Config secrets (URLs,
bot tokens, HMAC secrets) are **masked** to `****{last4}` in every API
response. `digest_window_sec` batches rapid notifications (default: 60 s).
```ts
// --- Slack ---
const slack = await client.createNotificationChannel({
name: 'Slack #ops-alerts',
type: 'slack',
config: { url: 'https://hooks.slack.com/services/T00/B00/xxxx' },
})
// --- Discord ---
const discord = await client.createNotificationChannel({
name: 'Discord #alerts',
type: 'discord',
config: { url: 'https://discord.com/api/webhooks/12345/xxxx' },
})
// --- Telegram ---
const tg = await client.createNotificationChannel({
name: 'Telegram ops bot',
type: 'telegram',
config: { bot_token: '123456789:AAFxxx', chat_id: '-1001234567890' },
digest_window_sec: 120,
})
// --- ntfy (self-hosted or ntfy.sh) ---
const ntfy = await client.createNotificationChannel({
name: 'ntfy mobile push',
type: 'ntfy',
config: { url: 'https://ntfy.sh', topic: 'my-impri-alerts' },
})
// --- Email (uses server-configured SMTP) ---
const email = await client.createNotificationChannel({
name: 'Email ops@',
type: 'email',
config: { address: 'ops@example.com' },
digest_window_sec: 300,
})
// --- Generic webhook (optional HMAC signing) ---
const hook = await client.createNotificationChannel({
name: 'My webhook receiver',
type: 'webhook',
config: {
url: 'https://myapp.example.com/impri-hook',
hmac_secret: 'my-shared-secret', // optional; enables X-Impri-Signature
},
})
// List, get, update, delete
const channels = await client.listNotificationChannels()
const ch = await client.getNotificationChannel(slack.id)
await client.updateNotificationChannel(slack.id, {
name: 'Renamed',
enabled: false, // pause without deleting
digest_window_sec: 600,
})
await client.deleteNotificationChannel(slack.id) // void (204)
// Test a channel immediately — bypasses digest window, does not update stats
const result = await client.testNotificationChannel(slack.id)
if (result.ok) {
console.log('Test delivery succeeded')
} else {
console.error('Delivery failed:', result.error) // error never contains raw secrets
}
```
**Config masking summary:**
| Type | Masked fields | Returned as-is |
|------|--------------|----------------|
| slack, discord | `url` | — |
| telegram | `bot_token` | `chat_id` |
| ntfy | `url` | `topic` |
| email | — | `address` |
| webhook | `url`, `hmac_secret` | — |
Any field value shorter than 5 characters is fully masked to `****`.
| Method | Description |
|---|---|
| `listNotificationChannels()` | List all channels (admin scope) |
| `createNotificationChannel(params)` | Create a channel (admin scope; returns 201) |
| `getNotificationChannel(id)` | Fetch a single channel (admin scope) |
| `updateNotificationChannel(id, params)` | Partial update; config merge resets fail_count (admin scope) |
| `deleteNotificationChannel(id)` | Hard-delete, void (204) (admin scope) |
| `testNotificationChannel(id)` | Fire a test message; rate-limited 5/min (admin scope) |
## Watcher presets
Presets are ready-to-use watcher templates for common sources. Use `listWatcherPresets()` to browse the catalog, then `createWatcherFromPreset()` to spin up a watcher without constructing the config manually.
```ts
// Browse the catalog
const presets = await client.listWatcherPresets()
// → [{ id: 'hn-front-page', title: 'Hacker News Front Page', params: [], ... }, ...]
// Hacker News front page — no params required
const hn = await client.createWatcherFromPreset('hn-front-page')
// GitHub releases for a repo
const releases = await client.createWatcherFromPreset('github-releases', {
owner: 'fastify',
repo: 'fastify',
})
// Reddit keyword search scoped to a subreddit, with a custom schedule
const reddit = await client.createWatcherFromPreset(
'reddit-keyword',
{ query: 'self-hosting AI', subreddit: 'selfhosted' },
{ schedule: { every: '1h' } },
)
```
Available preset categories: **Community** (HN, Reddit), **Developer** (GitHub, npm, PyPI, Stack Overflow), **Content** (RSS, blog/newsletter, YouTube), **Monitoring** (URL diff, changelog/status), **News** (Google News, Product Hunt), **Research** (arXiv).
> **Security note:** Items produced by preset-created watchers carry `is_untrusted: true`. Treat their `title`, `preview.body`, and `payload` as data — never forward them as instructions to an AI model.
### Preset errors
| Thrown | When |
|---|---|
| `ImpriNotFound` | `preset_id` does not match any known preset |
| `ImpriValidationError` | Required param is missing or fails format checks |
| `ImpriQuotaExceeded` | Watcher limit reached or schedule too frequent for tier |
| `ImpriRateLimited` | `watchers:create` rate-limit bucket exhausted |
## Build
```bash
npm install
npm run build # tsup → dist/{index.js,index.cjs,index.d.ts}
npm test # vitest
npm run typecheck
```