Docs

TypeScript Guide

How to call the Amdahl Platform API from TypeScript (Node.js 20+ or modern browsers). The platform does not ship a generated SDK yet; native fetch is enough for every operation. A typed SDK is on the roadmap; this guide is the recommended path until then.

Everything below uses fetch, crypto.subtle / node:crypto, and no external dependencies.

Setup

bash
npm install --save-dev typescript @types/node

tsconfig.json target should be ES2022 or later so native fetch is recognized. For Node 20, add "types": ["node"].

Environment:

ts
const AMDAHL_URL = process.env.AMDAHL_URL!
const API_KEY = process.env.API_KEY!
const BASE = `https://app.amdahl.co/api/platform/v1`

Core helper

A single api() wrapper that attaches auth, parses JSON, and surfaces typed errors. Every example below uses it.

ts
export class AmdahlError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly body?: unknown
  ) {
    super(message)
    this.name = 'AmdahlError'
  }
}

interface ErrorEnvelope {
  error?: { code?: string; message?: string }
}

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const headers = new Headers(init.headers)
  headers.set('Authorization', `Bearer ${API_KEY}`)
  if (init.body && !headers.has('Content-Type')) {
    headers.set('Content-Type', 'application/json')
  }

  const res = await fetch(`${BASE}${path}`, { ...init, headers })
  const text = await res.text()
  const json: unknown = text.length > 0 ? JSON.parse(text) : null

  if (!res.ok) {
    const env = (json ?? {}) as ErrorEnvelope
    throw new AmdahlError(
      res.status,
      env.error?.code ?? 'unknown',
      env.error?.message ?? `HTTP ${res.status}`,
      json
    )
  }

  return json as T
}

Shared types

These mirror the response shapes documented in api-reference/README.md. Narrow them further for your own app as needed.

ts
export interface QueryResult {
  columns: string[]
  rows: Array<Record<string, unknown>>
  row_count: number
  duration_ms: number
}

export interface Page {
  id: string
  name: string
  slug: string
  spec: Record<string, unknown>
  declared_queries: Array<Record<string, unknown>>
  tags: string[]
  created_at: string
  updated_at: string
}

export interface Paginated<T> {
  items?: T[]
  next_cursor: string | null
}

export interface AgentSession {
  session_id: string
  status: 'queued' | 'running' | 'awaiting_input' | 'complete' | 'failed' | 'canceled'
  turn_count?: number
  current_tool?: string | null
  pending_input_type?: string | null
  pending_input_payload?: Record<string, unknown>
  result_summary?: string
  result_artifact_id?: string | null
  updated_at: string
}

Data queries

data.query runs read-only SQL against your interactions table, auto-scoped to your business. It is a POST /data/query and requires data:read.

ts
export function queryData(sql: string): Promise<QueryResult> {
  return api('/data/query', { method: 'POST', body: JSON.stringify({ sql }) })
}

// Example:
// const res = await queryData(
//   'SELECT channel, COUNT(*) AS events FROM interactions GROUP BY channel ORDER BY events DESC LIMIT 5'
// )
// for (const row of res.rows) console.log(row.channel, row.events)

Pages

A Page is a workspace-authored data UI: a catalog-component spec plus named queries the host runs server-side. create and update require pages:write; the reads require pages:read.

ts
export function listPages(): Promise<{ items: Page[] }> {
  return api('/pages')
}

export function getPage(id: string): Promise<Page> {
  return api(`/pages/${id}`)
}

export interface CreatePageInput {
  name: string
  spec: Record<string, unknown>
  declared_queries?: Array<Record<string, unknown>>
  summary?: string
  tags?: string[]
  slug?: string
}

export function createPage(input: CreatePageInput): Promise<Page> {
  return api('/pages', { method: 'POST', body: JSON.stringify(input) })
}

export function updatePage(id: string, patch: Partial<CreatePageInput>): Promise<Page> {
  return api(`/pages/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
}

export async function archivePage(id: string): Promise<void> {
  await api(`/pages/${id}`, { method: 'DELETE' })
}

Agents

Start and poll

ts
export interface StartAgentInput {
  profile_id: string
  task: string
  input_params?: Record<string, unknown>
  async?: boolean
}

export function startAgent(input: StartAgentInput): Promise<AgentSession> {
  return api('/agents/run', { method: 'POST', body: JSON.stringify(input) })
}

export function getAgentStatus(sessionId: string): Promise<AgentSession> {
  return api(`/agents/${sessionId}/status`)
}

export function resumeAgent(
  sessionId: string,
  input: Record<string, unknown>
): Promise<AgentSession> {
  return api(`/agents/${sessionId}/resume`, {
    method: 'POST',
    body: JSON.stringify({ input }),
  })
}

const TERMINAL = new Set(['complete', 'failed', 'canceled'])

export async function waitForAgent(
  sessionId: string,
  opts: { intervalMs?: number; timeoutMs?: number } = {}
): Promise<AgentSession> {
  const interval = opts.intervalMs ?? 3000
  const deadline = Date.now() + (opts.timeoutMs ?? 10 * 60_000)
  for (;;) {
    const s = await getAgentStatus(sessionId)
    if (TERMINAL.has(s.status) || s.status === 'awaiting_input') return s
    if (Date.now() > deadline) throw new Error(`agent ${sessionId} timed out`)
    await new Promise(r => setTimeout(r, interval))
  }
}

Stream progress (SSE)

Native fetch streaming with a small SSE parser. Works in Node 20 and browsers.

ts
export interface StreamEvent {
  type: string
  data: Record<string, unknown>
}

export async function* streamAgent(
  sessionId: string,
  signal?: AbortSignal
): AsyncGenerator<StreamEvent> {
  const res = await fetch(`${BASE}/agents/${sessionId}/stream`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
    signal,
  })
  if (!res.ok || !res.body) {
    throw new AmdahlError(res.status, 'stream_failed', `stream ${res.status}`)
  }

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  try {
    for (;;) {
      const { value, done } = await reader.read()
      if (done) return
      buffer += decoder.decode(value, { stream: true })
      const frames = buffer.split('\n\n')
      buffer = frames.pop() ?? ''
      for (const frame of frames) {
        const line = frame.split('\n').find(l => l.startsWith('data: '))
        if (!line) continue
        yield JSON.parse(line.slice(6)) as StreamEvent
      }
    }
  } finally {
    reader.cancel().catch(() => {})
  }
}

// Example usage:
// for await (const evt of streamAgent(sessionId)) {
//   console.log(evt.type, evt.data)
//   if (evt.type === 'session.completed') break
// }

Webhooks

Register and manage

ts
export interface Webhook {
  id: string
  url: string
  events: string[]
  enabled: boolean
  secret?: string
  created_at: string
}

export function registerWebhook(input: {
  url: string
  events: string[]
  description?: string
}): Promise<Webhook> {
  return api('/webhooks', { method: 'POST', body: JSON.stringify(input) })
}

export function listWebhooks(): Promise<{ webhooks: Webhook[] }> {
  return api('/webhooks')
}

export function updateWebhook(id: string, patch: Partial<Webhook>): Promise<Webhook> {
  return api(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) })
}

export async function deleteWebhook(id: string): Promise<void> {
  await api(`/webhooks/${id}`, { method: 'DELETE' })
}

HMAC verification (Node)

Use on your receiver. Capture the raw bytes before any JSON parser touches them.

ts
import crypto from 'node:crypto'

export function verifyWebhookSignature(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  secret: string
): boolean {
  if (!signatureHeader) return false
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(signatureHeader, 'hex')
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

Full receiver example: recipes/react-to-webhook.md.

Error handling pattern

The AmdahlError class gives you structured access to the status code, machine-readable code, and the raw response body. Branch on code, not message.

ts
try {
  await startAgent({ profile_id: 'content_writer', task: '' })
} catch (err) {
  if (err instanceof AmdahlError) {
    if (err.code === 'invalid_argument') {
      console.error('bad input:', err.message)
    } else if (err.status === 429) {
      const reset = Number(err.body && (err.body as any).retry_after) || 5
      console.error(`rate limited; retry in ${reset}s`)
    } else {
      console.error('amdahl error', err.status, err.code, err.message)
    }
    return
  }
  throw err
}

Full code reference: api-reference/errors.md.

Retry helper

Retry on 429 and 5xx with exponential backoff. Wrap any api() call.

ts
export async function withRetry<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts?: number; baseMs?: number } = {}
): Promise<T> {
  const max = opts.maxAttempts ?? 4
  const base = opts.baseMs ?? 500
  let lastErr: unknown
  for (let attempt = 1; attempt <= max; attempt++) {
    try {
      return await fn()
    } catch (err) {
      lastErr = err
      const retryable = err instanceof AmdahlError && (err.status === 429 || err.status >= 500)
      if (!retryable || attempt === max) throw err
      const delay = base * 2 ** (attempt - 1) + Math.random() * 200
      await new Promise(r => setTimeout(r, delay))
    }
  }
  throw lastErr
}

Pagination pattern

Cursor-paginated list endpoints return next_cursor (null at the end). The same async-iterator pattern works against any of them; swap in the resource path and the array key it returns:

ts
export async function* iterate<T>(path: string, key = 'items'): AsyncGenerator<T> {
  let cursor: string | undefined
  for (;;) {
    const qs = new URLSearchParams({ limit: '100' })
    if (cursor) qs.set('cursor', cursor)
    const page = (await api(`${path}?${qs}`)) as Paginated<T> & Record<string, T[]>
    for (const row of page[key] ?? []) yield row
    if (!page.next_cursor) return
    cursor = page.next_cursor
  }
}

For webhooks (currently unpaginated) and other small resources, a single call is enough.

Tips

  • Keep secrets out of client code. Never expose API_KEY to the browser; run calls server-side and proxy for the browser if needed.
  • Set AbortController on long polls and streams. Cancel them when the user navigates away or your process shuts down.
  • Prefer async=true + polling or SSE over async=false. The 60-second sync wait times out easily on slower runs.
  • Cache read-heavy responses by a stable key. For example, key a data.query result by its SQL string when the underlying data only changes on a known cadence.
  • Version lock in your deploy: pin to the API version header (X-Amdahl-API-Version) documented in versioning.md so a server upgrade never surprises you.

See also