@fullkontrol/ssi
v0.3.0
Published
Type-safe TypeScript client for the fullkontrol SSI API
Readme
@fullkontrol/ssi
Type-safe TypeScript client for the fullkontrol SSI API.
- Fully typed — every method parameter and return value is typed from the live OpenAPI spec
- Lightweight — built on openapi-fetch (~6 KB)
- Auto-generated — no manual type definitions to maintain
- Universal — ships ESM + CJS
Install
npm install @fullkontrol/ssi
# or
bun add @fullkontrol/ssiQuick start
import { FullkontrolClient } from "@fullkontrol/ssi";
const fk = new FullkontrolClient({
baseUrl: "https://alerts.fullkontrol.app",
apiKey: "fk_abc123...",
});
// Send an alert — the most common operation
await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: "user_123",
title: "Task reminder",
body: "Your task is due in 30 minutes",
});Constructor options
| Option | Type | Description |
|--------|------|-------------|
| baseUrl | string | Required — scheduler API base URL |
| apiKey | string | Injected as x-service-token: Bearer <key> |
| fetch | typeof fetch | Custom fetch implementation (useful for testing) |
API reference
fk.alert() — top-level alert primitive
A high-level primitive for the most common operation: send an alert to someone about a task. Handles field mapping, Date conversion, and fan-out internally.
// Send immediately to one user
const result = await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: "user_123",
title: "Task reminder",
body: "Your task is due in 30 minutes",
});
// result: { to, run_id, dispatched: true, dispatch: { accepted, outcome, ... } }
// Fan-out to multiple recipients — never throws, errors inline
import { isAlertError } from "@fullkontrol/ssi";
const results = await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: ["user_1", "user_2", "user_3"],
title: "Team alert",
body: "Task needs attention",
});
for (const r of results) {
if (isAlertError(r)) {
console.error(`Failed for ${r.to}: ${r.error}`);
} else {
console.log(`Sent to ${r.to}: run ${r.run_id}`);
}
}
// Schedule for later
await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: "user_123",
title: "Task reminder",
body: "Your task is due in 30 minutes",
at: "2025-06-15T14:00:00Z",
});
// RRULE schedule — creates a definition + materializes runs in one call
const scheduled = await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: "user_123",
title: "Daily standup reminder",
body: "Standup starts in 15 minutes",
schedule: {
rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0",
timezone: "Europe/Oslo",
},
});
// scheduled.schedule: { definition_id, created, already_exists, run_ids }
// WhatsApp instead of push
await fk.alert({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
to: "user_123",
channel: "whatsapp",
title: "Urgent",
body: "Task overdue",
});AlertInput fields
| Field | Type | Description |
|-------|------|-------------|
| workspace_id | string | Required |
| place_id | string | Required |
| task_id | string | Required |
| to | string \| string[] | Required — recipient user ID(s). Arrays fan-out to parallel calls |
| title | string | Required — notification title |
| body | string | Required — notification body |
| channel | "push" \| "whatsapp" | Delivery channel (default "push") |
| at | string \| Date | Schedule for later. Omit for immediate dispatch |
| timezone | string | IANA timezone for interpreting local at values |
| schedule | AlertScheduleInput | RRULE schedule — creates a definition and materializes runs server-side |
AlertScheduleInput
| Field | Type | Description |
|-------|------|-------------|
| rrule | string | Required — RFC 5545 RRULE string (e.g. FREQ=DAILY;BYHOUR=9;BYMINUTE=0) |
| timezone | string | Required — IANA timezone (e.g. Europe/Oslo) |
| task_status | string | Task status for the materialization context (default "open") |
| task_title | string | Task title for the materialization context (defaults to alert title) |
When schedule is provided, dispatch_immediately is forced to false. The backend creates an alert definition with trigger_type: "rrule" and immediately materializes runs. The result includes a schedule object with the created definition_id, run counts, and run_ids.
fk.alert.update() — update task alerts
Cancels existing alert runs for a task and re-materializes them based on updated task state. Wraps POST /v1/planner/replan-task into a flat, ergonomic interface.
// Task was rescheduled — cancel old runs and create new ones
const result = await fk.alert.update({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
timezone: "America/New_York",
scheduled_at: new Date("2026-03-01T09:00:00Z"),
task_status: "todo",
task_title: "Morning check",
assignee_user_id: "user_1",
});
// result: { cancelled_runs: 2, result: { created: 1, already_exists: 0, skipped: 1, run_ids: ["run_new"] }, inspected_definitions: 3 }
// Provide a reason for auditing
await fk.alert.update({
workspace_id: "ws_1",
place_id: "p_1",
task_id: "t_1",
timezone: "Europe/Oslo",
scheduled_at: "2026-03-01T10:00:00Z",
task_status: "open",
task_title: "Weekly inspection",
reason: "schedule_changed",
});By default all matching definitions are replanned. Pass definition_ids to scope to specific ones (from fk.definitions.list() or fk.definitions.create()).
fk.alerts.send() — low-level direct send
// Send an alert (dispatches immediately by default)
await fk.alerts.send({
workspace_id: "ws_1",
place_id: "place_1",
task_id: "task_1",
recipient_user_id: "user_1",
title: "Test notification",
body: "This is a test alert",
});
// Schedule for later (dispatch_immediately defaults to false when scheduled_for is set)
await fk.alerts.send({
workspace_id: "ws_1",
place_id: "place_1",
task_id: "task_1",
recipient_user_id: "user_1",
title: "Scheduled test",
body: "Will be dispatched later",
scheduled_for: "2025-06-15T14:00:00Z",
});
// RRULE schedule (creates definition + materializes in one call)
const result = await fk.alerts.send({
workspace_id: "ws_1",
place_id: "place_1",
task_id: "task_1",
recipient_user_id: "user_1",
title: "Daily check",
body: "Time for your daily check",
schedule: {
rrule: "FREQ=DAILY;BYHOUR=10;BYMINUTE=0",
timezone: "America/New_York",
},
});
// result.schedule: { definition_id, created, already_exists, run_ids }fk.definitions
// Create a new alert definition
await fk.definitions.create({
workspace_id: "ws_1",
trigger_type: "relative_to_task_time",
offsets: ["-30m"],
condition: { allowed_statuses: ["open"], stop_statuses: ["done"] },
recipients: { assignee: true },
message_template: { title: "Reminder", body: "Task is due soon" },
created_by: "admin",
});
// List definitions for a workspace
await fk.definitions.list({ workspace_id: "ws_1" });
await fk.definitions.list({ workspace_id: "ws_1", enabled: "true" });
// Update a definition
await fk.definitions.update("def_id", { enabled: false });
await fk.definitions.update("def_id", { offsets: ["-15m", "-1h"] });fk.planner
// Materialize alert runs for a task
await fk.planner.materialize({
task: {
workspace_id: "ws_1",
place_id: "place_1",
task_id: "task_1",
timezone: "Europe/Oslo",
scheduled_at: "2025-06-15T14:00:00Z",
task_status: "open",
task_title: "Weekly inspection",
},
});
// Replan after a task changes (cancels existing runs + re-materializes)
await fk.planner.replanTask({
task: {
workspace_id: "ws_1",
place_id: "place_1",
task_id: "task_1",
timezone: "Europe/Oslo",
scheduled_at: "2025-06-16T10:00:00Z",
task_status: "open",
task_title: "Weekly inspection",
},
reason: "schedule_changed",
});fk.runs
// List runs with filters
await fk.runs.list({ workspace_id: "ws_1", limit: 50 });
await fk.runs.list({ workspace_id: "ws_1", status: "failed" });
await fk.runs.list({
workspace_id: "ws_1",
task_id: "task_1",
from: "2025-06-01T00:00:00Z",
to: "2025-06-30T23:59:59Z",
});
// Get a single run
const run = await fk.runs.get("run_id");
// Get delivery attempts for a run
const { attempts } = await fk.runs.attempts("run_id");
const { attempts: last5 } = await fk.runs.attempts("run_id", { limit: 5 });
// Get status transition events for a run
const { events } = await fk.runs.events("run_id");
// Cancel all pending runs for a task
await fk.runs.cancelByTask({
workspace_id: "ws_1",
task_id: "task_1",
reason: "task_deleted",
});fk.runs.deadLetter
// List dead-lettered runs
await fk.runs.deadLetter.list({ workspace_id: "ws_1" });
// Redrive a single run (resets to planned status)
await fk.runs.deadLetter.redrive({ run_id: "run_abc" });
// Bulk redrive
await fk.runs.deadLetter.redriveBulk({
run_ids: ["run_abc", "run_def"],
});fk.apiKeys
// Create a new API key (plaintext returned only once)
const { key, id } = await fk.apiKeys.create({
label: "Firebase integration",
created_by: "admin",
});
console.log(key); // fk_a1b2c3...
// List all keys (metadata only, no secrets)
const { keys } = await fk.apiKeys.list();
// Revoke a key
await fk.apiKeys.revoke("key_id");fk.auth
// Issue a short-lived service JWT
const { token, expires_at } = await fk.auth.issueToken({ ttl_seconds: 900 });
// Use defaults
const jwt = await fk.auth.issueToken();Error handling
All methods throw FullkontrolApiError on non-2xx responses:
import { FullkontrolClient, FullkontrolApiError } from "@fullkontrol/ssi";
try {
await fk.definitions.update("nonexistent", { enabled: false });
} catch (e) {
if (e instanceof FullkontrolApiError) {
console.log(e.status); // 404
console.log(e.message); // "Definition not found: nonexistent"
console.log(e.body); // { error: "Definition not found: nonexistent" }
}
}| Status | Meaning | |--------|---------| | 400 | Validation error (check request body) | | 401 | Unauthorized (missing or invalid API key) | | 404 | Resource not found | | 500 | Server error | | 503 | Auth not configured / service unavailable |
Examples
Firebase Functions: materialize on task create
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { FullkontrolClient } from "@fullkontrol/ssi";
const fk = new FullkontrolClient({
baseUrl: process.env.SCHEDULER_URL!,
apiKey: process.env.SCHEDULER_API_KEY!,
});
export const onTaskCreated = onDocumentCreated(
"workspaces/{workspaceId}/tasks/{taskId}",
async (event) => {
const task = event.data?.data();
if (!task) return;
await fk.planner.materialize({
task: {
workspace_id: event.params.workspaceId,
place_id: task.place_id,
task_id: event.params.taskId,
timezone: task.timezone,
scheduled_at: task.scheduled_at,
task_status: task.status,
task_title: task.title,
assignee_user_id: task.assignee_id,
owner_user_id: task.owner_id,
},
});
}
);Firebase Functions: replan on task update
export const onTaskUpdated = onDocumentUpdated(
"workspaces/{workspaceId}/tasks/{taskId}",
async (event) => {
const before = event.data?.before.data();
const after = event.data?.after.data();
if (!before || !after) return;
const scheduleChanged = before.scheduled_at !== after.scheduled_at;
const statusChanged = before.status !== after.status;
if (!scheduleChanged && !statusChanged) return;
await fk.alert.update({
workspace_id: event.params.workspaceId,
place_id: after.place_id,
task_id: event.params.taskId,
timezone: after.timezone,
scheduled_at: after.scheduled_at,
task_status: after.status,
task_title: after.title,
assignee_user_id: after.assignee_id,
owner_user_id: after.owner_id,
reason: scheduleChanged ? "schedule_changed" : "status_changed",
});
}
);Firebase Functions: cancel on task delete
export const onTaskDeleted = onDocumentDeleted(
"workspaces/{workspaceId}/tasks/{taskId}",
async (event) => {
await fk.runs.cancelByTask({
workspace_id: event.params.workspaceId,
task_id: event.params.taskId,
reason: "task_deleted",
});
}
);Dead-letter monitoring cron
import { onSchedule } from "firebase-functions/v2/scheduler";
export const checkDeadLetters = onSchedule("every 1 hours", async () => {
const { data } = await fk.runs.deadLetter.list({ limit: 500 });
if (data.length === 0) return;
console.warn(`${data.length} dead-lettered runs found`);
// Auto-redrive all
await fk.runs.deadLetter.redriveBulk({
run_ids: data.map((run) => run.id),
});
});Using types for your own interfaces
import type {
AlertDefinition,
AlertRun,
AlertRunAttempt,
CreateDefinitionInput,
} from "@fullkontrol/ssi";
function formatRun(run: AlertRun): string {
return `[${run.status}] ${run.id} scheduled for ${run.scheduled_for}`;
}Development
bun install # Install deps
bun run --filter @fullkontrol/ssi typecheck # Type check
bun run --filter @fullkontrol/ssi test # Run tests
bun run --filter @fullkontrol/ssi build # Build ESM + CJS + declarationsRegenerating types
If the API schema changes, regenerate types from a running dev server:
# Start the scheduler
bun run --filter @fullkontrol/alerts-server dev
# Regenerate (defaults to http://localhost:8787)
bun run --filter @fullkontrol/ssi generate
# Or point to a different URL
OPENAPI_URL=https://alerts.fullkontrol.app bun run --filter @fullkontrol/ssi generateThe generated file at src/generated/openapi.ts is committed to git — consumers never need to run generation.
License
MIT
