# Typescript

# 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](../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](../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](../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](../versioning.md) so a server upgrade never surprises you.

## See also

- [quickstart.md](../quickstart.md) for your first end-to-end call.
- [sdk/curl.md](./curl.md) for the raw HTTP equivalent of every helper here.
- [sdk/python.md](./python.md) for the Python equivalent.
- [recipes/draft-blog-post.md](../recipes/draft-blog-post.md) for an end-to-end workflow using these helpers.
- [api-reference/README.md](../api-reference/README.md) for the full endpoint catalog.
