Package Exports
- @headsdown/sdk
Readme
@headsdown/sdk
TypeScript client SDK for the HeadsDown API. Gives AI agents and developer tools typed access to HeadsDown calls, availability boundaries, focus modes, and task verdicts.
Zero dependencies. Node 18+ (uses native fetch).
Install
npm install @headsdown/sdkQuick Start
import { HeadsDownClient } from "@headsdown/sdk";
// From an explicit API key
const client = new HeadsDownClient({ apiKey: "hd_..." });
// From saved credentials (~/.config/headsdown/credentials.json)
const client = await HeadsDownClient.fromCredentials();
// Or from the HEADSDOWN_API_KEY environment variable
const client = new HeadsDownClient();
// Optional: set default actor context for delegated authorization
const scopedClient = new HeadsDownClient({
actorContext: { source: "pi", sessionId: "session-123" },
});Authentication
The SDK uses Device Flow (OAuth 2.0) for authentication. This is the "scan a code" flow designed for CLI tools and agents.
import { HeadsDownClient } from "@headsdown/sdk";
const client = await HeadsDownClient.authenticate(
(auth) => {
console.log(`Open ${auth.verificationUriComplete}`);
console.log(`Or go to ${auth.verificationUri} and enter: ${auth.userCode}`);
},
{ label: "My Tool" },
);
// Credentials are saved automatically to ~/.config/headsdown/credentials.jsonFor lower-level control over the auth flow:
import { DeviceFlow, CredentialStore } from "@headsdown/sdk";
const flow = new DeviceFlow();
const auth = await flow.start("My Tool");
console.log(`Enter code ${auth.userCode} at ${auth.verificationUri}`);
const apiKey = await flow.poll(auth.deviceCode, auth.interval, auth.expiresIn);
const store = new CredentialStore();
await store.save(apiKey, "My Tool");Usage
Read the HeadsDown call
Lead with what HeadsDown recommends for the run, then use availability as supporting context.
import { resolveHeadsDownCallFallback } from "@headsdown/sdk";
const overview = await client.getAgentControlOverview();
const call = resolveHeadsDownCallFallback(overview.headsdownCall);
console.log(`${call.title}: ${call.body}`);
console.log(`HeadsDown call: ${call.primaryActionKey ?? call.primaryActionIntent}`);Apply canonical HeadsDown actions
Use the built-in action helpers so clients do not hand-roll mutation payloads. Helpers send canonical action keys and derive idempotency keys by default.
const queued = await client.queueForMorning({ runId: "run_123" });
console.log(queued.result?.actionKey); // "queue_for_morning"
await client.continueRun({ runId: "run_123" });
await client.continueWithLimit({ runId: "run_123" });
await client.askUser({ runId: "run_123" });
await client.queueForLater({ runId: "run_123" });
await client.pauseAndSummarize({
runId: "run_123",
reason: "Rabbit hole detected. Save the handoff.",
});
await client.allowForDuration({
runId: "run_123",
durationMinutes: 15,
reason: "One targeted validation path",
});
await client.allowOnce({ runId: "run_123" });
await client.createTemporaryException({
runId: "run_123",
durationMinutes: 30,
mode: "limited",
});
await client.keepQueued({ runId: "run_123" });
await client.resumeRun({ runId: "run_123" });
await client.narrowScope({ runId: "run_123" });
await client.stopRun({ runId: "run_123" });
// For a lower-level integration, use the typed generic action surface.
await client.applyHeadsDownAction("queue_for_morning", { runId: "run_123" });Check Availability
Use availability to understand the user’s current boundary when a workflow still needs the lower-level schedule or mode inputs.
const { contract, schedule } = await client.getAvailability();
if (contract?.mode === "busy") {
console.log(`User is in focus mode: ${contract.statusText}`);
console.log(`Expires at: ${contract.expiresAt}`);
}
if (!schedule.inReachableHours) {
console.log(`Not currently reachable. Next transition: ${schedule.nextTransitionAt}`);
}
if (schedule.wrapUpGuidance.active) {
console.log(`Wrap-Up active: ${schedule.wrapUpGuidance.remainingMinutes} minutes left`);
console.log(`Guidance source: ${schedule.wrapUpGuidance.source}`);
}You can also check availability at a specific point in time:
const later = await client.getAvailability({ at: "2025-06-16T09:00:00Z" });Execution Directive (cold-start model guidance)
Use describeExecutionDirective() to convert availability, Wrap-Up guidance, and verdict context into one canonical instruction payload for LLMs.
import { describeExecutionDirective } from "@headsdown/sdk";
const { contract, schedule } = await client.getAvailability();
const verdict = await client.submitProposal({
agentRef: "pi",
framework: "pi",
description: "Ship the scoped fix",
});
const directive = describeExecutionDirective({ contract, schedule, verdict });
console.log(directive.primaryDirective);Actor Context (session/workspace-aware authorization)
For endpoints protected by delegation grants, the backend can require actor context metadata. Set it once at client construction, or override it for one call with withActor().
const client = new HeadsDownClient({
apiKey: "hd_...",
actorContext: { source: "pi", sessionId: "session-default" },
});
// Per-request override via a derived client
await client
.withActor({
source: "pi",
workspaceRef: "headsdown/headsdown-pi",
sessionId: "session-override",
})
.submitProposal({ agentRef: "pi", description: "Refactor auth resolver" });Transport format is a single HTTP header:
x-headsdown-actor-context: {"source":"...","agentId":"...?","sessionId":"...?","workspaceRef":"...?"}
Submit a Task Proposal
Ask HeadsDown whether a task should proceed given the user's current availability.
const verdict = await client.submitProposal({
agentRef: "my-agent",
description: "Refactor the auth module to use JWT tokens",
estimatedFiles: 4,
estimatedMinutes: 30,
scopeSummary: "4 files in lib/auth/",
sourceRef: "ticket-142", // provenance (ticket/PR/chat reference)
idempotencyKey: "pi-toolcall-123", // retry safety for this invocation
deliveryMode: "auto", // optional: "auto" | "wrap_up" | "full_depth"
});
if (verdict.decision === "approved") {
// Proceed with the task
if (verdict.wrapUpGuidance?.active) {
console.log(`Wrap-Up is active until ${verdict.wrapUpGuidance.deadlineAt}`);
}
} else {
// verdict.decision === "deferred"
console.log(`Deferred: ${verdict.reason}`);
}List Past Proposals
// All proposals
const proposals = await client.listProposals();
// Only deferred, last 5
const deferred = await client.listProposals({ verdict: "deferred", latest: 5 });Presets
const presets = await client.listPresets();
const contract = await client.applyPreset(presets[0].id);Create a Contract Directly
const contract = await client.createContract({
mode: "busy",
autoRespond: true,
status: true,
statusText: "Deep work",
statusEmoji: "🔨",
duration: 120, // minutes
ruleSetType: "focus",
ruleSetParams: { maxInterruptions: 0 },
});
// Two-axis boundary: createContract is availability status only.
// Wrap-Up fields are rejected here and should be set via updateVerdictSettings or deliveryMode.Delegation Grants
Delegation grant management mutations now require a session-token auth path in the backend. Because this SDK authenticates with API keys, create/revoke grant calls will raise an auth error with actionable guidance.
try {
await client.createDelegationGrant({
scope: "session",
sessionId: "session-123",
permissions: ["availability_override_create", "availability_override_cancel"],
durationMinutes: 30,
source: "pi",
});
} catch (error) {
// AuthError: Delegation grant management requires a session-token auth path.
}Verdict and Calibration Utilities
const interrupt = await client.evaluateInterrupt("brezn");
if (!interrupt.allowed) {
console.log(interrupt.autoResponse ?? interrupt.reason);
}
const settings = await client.getVerdictSettings();
const updated = await client.updateVerdictSettings({
thresholds: {
online: { maxFiles: null, maxEstimatedMinutes: null },
busy: { maxFiles: 5, maxEstimatedMinutes: 30 },
limited: { maxFiles: 3, maxEstimatedMinutes: 10 },
offline: { maxFiles: 0, maxEstimatedMinutes: 0 },
},
defaultWrapUpMode: "auto",
wrapUpThresholdMinutes: 30,
});
const profiles = await client.listCalibrationProfiles();User Profile
const profile = await client.getProfile();
console.log(`Authenticated as ${profile.name} (${profile.email})`);Error Handling
The SDK uses a typed error hierarchy. All errors extend HeadsDownError.
import {
AuthError,
ApiError,
NetworkError,
ValidationError,
HeadsDownActionInvalidStateError,
HeadsDownActionExpiredError,
HeadsDownActionFeatureDisabledError,
HeadsDownActionAuthError,
} from "@headsdown/sdk";
try {
await client.pauseAndSummarize({
runId: "run_123",
reason: "Rabbit hole detected. Save the handoff.",
});
} catch (error) {
if (error instanceof HeadsDownActionInvalidStateError) {
// Action no longer matches the current call state.
} else if (error instanceof HeadsDownActionExpiredError) {
// Action request expired before apply.
} else if (error instanceof HeadsDownActionFeatureDisabledError) {
// Backend feature is disabled for this workspace/user.
} else if (error instanceof HeadsDownActionAuthError || error instanceof AuthError) {
// Auth failed. Re-authenticate or refresh actor context.
} else if (error instanceof NetworkError) {
// Connection failed, DNS error, or timeout.
} else if (error instanceof ValidationError) {
// Bad input (for example missing runId or invalid durationMinutes).
} else if (error instanceof ApiError) {
// Server returned an error. Check error.status, error.graphqlErrors.
}
}Configuration
const client = new HeadsDownClient({
apiKey: "hd_...", // API key (or set HEADSDOWN_API_KEY)
baseUrl: "https://headsdown.app", // API base URL
timeout: 30000, // Request timeout in ms
fetch: customFetch, // Custom fetch implementation
retry: { retries: 2, retryDelayMs: 250 }, // Transient failure retries
hooks: {
onRetry: ({ attempt, reason }) => console.log(`retry #${attempt + 1}: ${reason}`),
},
actorContext: { source: "pi", sessionId: "session-123" },
});| Option | Default | Description |
|---|---|---|
apiKey |
HEADSDOWN_API_KEY env var |
HeadsDown API key (hd_ prefix) |
baseUrl |
https://headsdown.app |
API endpoint |
timeout |
30000 |
Request timeout in milliseconds |
fetch |
globalThis.fetch |
Custom fetch (for testing or proxies) |
retry.retries |
2 |
Number of retries for transient failures |
retry.retryDelayMs |
250 |
Base retry delay in ms (exponential backoff) |
hooks |
undefined |
Optional onRequest/onResponse/onRetry hooks |
actorContext |
undefined |
Default actor context sent as x-headsdown-actor-context |
Data Transparency
This SDK sends requests only to the HeadsDown API (https://headsdown.app/graphql by default). Every request includes your API key as a Bearer token. The exact GraphQL queries are in src/queries.ts, readable in full.
What is sent: Your API key, the specific query/mutation being executed (HeadsDown call reads, canonical action applies, availability checks, task proposals, preset operations, verdict settings, interrupt evaluation), and optional actor context metadata when configured (source, agentId, sessionId, workspaceRef).
What is received: HeadsDown call metadata, availability boundaries, schedule resolution, task verdicts, calibration data, and preset configurations.
What is stored locally: Your API key at ~/.config/headsdown/credentials.json (file permissions: 0600, user-only read/write).
No telemetry. No analytics. No third-party requests.
Schema Sync
The app repo is the source of truth for the GraphQL schema, and this SDK only consumes a pushed schema file.
When the app exports a new schema, import it here:
npm run schema:sync -- --source /absolute/path/to/schema.json(Equivalent env var form: HEADSDOWN_SCHEMA_SOURCE=/absolute/path/to/schema.json npm run schema:sync.)
Then regenerate operation and variable types:
npm run codegen:typesIf a ticket only updates SDK runtime normalization or exported TypeScript aliases without changing src/queries.ts or the schema snapshot, regeneration is not required.
The schema compatibility test uses this local snapshot to make drift obvious in CI.
Releases
Releases are tag-driven. Push a tag like v0.1.0 and GitHub Actions will run tests, verify the tag matches package.json, and publish to npm using trusted publishing.
git tag v0.1.0
git push origin v0.1.0Before the first publish, configure npm trusted publishing for this repository and make sure the package version matches the tag.
License
MIT