---
title: "@outputai/http"
description: "Make traceable HTTP requests from your steps"
---

The `@outputai/http` package gives you an HTTP client that automatically shows up in your [traces](/operations/tracing). Every request — URL, method, status code, timing — is recorded as a child node in the trace tree, so you can see exactly what API calls your steps made and how long they took.

Under the hood, it wraps [ky](https://github.com/sindresorhus/ky), a lightweight HTTP client built on `fetch`.

## Creating a Client

The typical pattern is to create a client per external service in your [clients directory](/clients), then import it in your steps:

```typescript clients/jina.ts
import { httpClient } from '@outputai/http';

export const jina = httpClient({
  prefixUrl: 'https://r.jina.ai',
  headers: {
    'Authorization': `Bearer ${process.env.JINA_API_KEY}`,
    'Accept': 'application/json'
  },
  timeout: 30000
});
```

You can extend clients to create specialized instances:

```typescript
const authenticatedClient = jina.extend({
  headers: { 'X-Custom-Header': 'value' }
});
```

## HTTP Methods

```typescript
// GET
const response = await client.get('https://acme.com');
const data = await response.json();

// POST
const response = await client.post('companies', {
  json: { url: 'https://acme.com' }
});
const result = await response.json();

// PUT
await client.put('companies/123', {
  json: { industry: 'SaaS' }
});

// DELETE
await client.delete('companies/123');
```

## Response Bodies

`@outputai/http` follows normal `fetch` semantics: callers own the returned response body. Always consume the body with
`.json()`, `.text()`, `.arrayBuffer()`, or another body reader when you need the payload.

If you only need response metadata from a non-`HEAD` request, such as `response.url`, `response.status`, or headers, cancel
the unused body so the underlying connection and native buffers can be released:

```typescript
const response = await client.get('https://example.com', {
  redirect: 'follow'
});

try {
  return response.url;
} finally {
  await response.body?.cancel();
}
```

## Using in Steps

Wrap HTTP calls in steps for automatic retry and tracing:

```typescript steps.ts
import { step } from '@outputai/core';
import { jina } from '../../clients/jina.js';
import { ScrapePageInput, ScrapePageOutput } from './types.js';

export const scrapePage = step({
  name: 'scrapePage',
  description: 'Scrape a web page using Jina Reader API',
  inputSchema: ScrapePageInput,
  outputSchema: ScrapePageOutput,
  fn: async (url) => {
    const response = await jina.get(url);
    const data = await response.json();

    return {
      title: data.data?.title ?? '',
      content: data.data?.content ?? '',
      url: data.data?.url ?? url
    };
  }
});

// types.ts
// import { z } from '@outputai/core';
//
// export const ScrapePageInput = z.string();
//
// export const ScrapePageOutput = z.object({
//   title: z.string(),
//   content: z.string(),
//   url: z.string()
// });
```

## Tracing

All requests made with `@outputai/http` are automatically traced — no configuration needed. In your trace files, HTTP calls appear as children of the step that made them:

```json
{
  "kind": "http",
  "name": "request",
  "input": { "method": "GET", "url": "https://r.jina.ai/https://acme.com" },
  "output": { "status": 200, "statusText": "OK" }
}
```

See [Tracing](/operations/tracing) for details on the trace format.

## Request Events

Every HTTP call made through `@outputai/http`'s `fetch` (and the higher-level `httpClient`) emits an **`http:request`** hook event — independent of whether you attach a cost. Subscribe to it with [`on`](/packages/core#hooks) for logging, alerting, or per-vendor metrics:

```typescript
import { on } from '@outputai/core/hooks';
import type { HttpRequestEvent } from '@outputai/http';

on<HttpRequestEvent>( 'http:request', async ( payload ) => {
  // payload: { eventId, eventDate, requestId, method, url, status?, durationMs, outcome, activityInfo, workflowDetails, outputActivityKind }
  console.log( `[${payload.outcome}] ${payload.method} ${payload.url} ${payload.status ?? '-'} ${payload.durationMs}ms` );
} );
```

The payload includes:

| Field | Description |
|-------|-------------|
| `eventId` | UUID v4 stamped per emit. Stable per-emit idempotency key — `http:request` and `cost:http:request` for the same fetch get distinct `eventId`s, so dedup keyed on `eventId` won't collapse the two. |
| `eventDate` | Millisecond epoch timestamp for when the event was emitted. |
| `requestId` | UUID generated per request inside `@outputai/http`. Shared between the matching `http:request` and `cost:http:request` events for cross-event correlation. |
| `method` | HTTP method (uppercase). |
| `url` | Absolute request URL. |
| `status` | HTTP status code; `undefined` on network failure. |
| `durationMs` | Elapsed time from request issuance to response (or failure), in milliseconds. |
| `outcome` | One of `'success'` (2xx-3xx), `'error'` (status >= 400), `'failure'` (DNS / timeout / abort). |
| `activityInfo` | Temporal [`activity.Info`](https://typescript.temporal.io/api/interfaces/activity.Info) for the activity that made the request. |
| `workflowDetails` | Output's serializable subset of Temporal [`workflow.WorkflowInfo`](https://typescript.temporal.io/api/interfaces/workflow.WorkflowInfo). |
| `outputActivityKind` | Output activity kind. Possible values are `step`, `evaluator`, and `internal_step`. |

The event fires for **every** call — success, non-2xx responses, and network failures alike. The existing [`cost:http:request`](/costs/cost-events#http-request-cost) event is unchanged and continues to fire only when you call `addRequestCost`.

## Attaching Request Cost

When you know the dollar cost of an HTTP request (for example from provider billing headers), you can attach it to the HTTP trace event with `addRequestCost`.

```typescript
import { httpClient, addRequestCost } from '@outputai/http';

const client = httpClient( { prefixUrl: 'https://api.vendor.com' } );

const response = await client.post( 'search', {
  json: { query: 'acme' }
} );

const inputCost = Number( response.headers.get( 'x-cost-input-usd' ) ?? 0 );
const outputCost = Number( response.headers.get( 'x-cost-output-usd' ) ?? 0 );

addRequestCost( response, inputCost + outputCost );

response.body?.cancel();
```

`addRequestCost` accepts the total request cost as a number.

`addRequestCost` only works with responses created by `@outputai/http` (or its exported `fetch`). If the response did not come from this package, the function safely no-ops and logs a warning.

It also emits a **`cost:http:request`** hook event (same hooks system as [`cost:llm:request`](/costs/cost-events)). For the payload and examples, see [Cost Events — HTTP](/costs/cost-events#http-request-cost).

## Error Handling

The client throws `HTTPError` for non-2xx responses and `TimeoutError` for timeouts:

```typescript
import { HTTPError, TimeoutError } from '@outputai/http';

try {
  const response = await jina.get(url);
  return response.json();
} catch (error) {
  if (error instanceof HTTPError) {
    if (error.response.status === 404) {
      return null; // Page not found
    }
    // 429, 500, etc. — let the step retry
    throw error;
  }
  if (error instanceof TimeoutError) {
    throw error; // Step will retry
  }
  throw error;
}
```

Since steps retry automatically, you generally just need to handle cases where retrying won't help (like a 404). For everything else, let the error propagate and the step's retry policy will handle it.

## Environment Variables

| Variable | Description |
|----------|-------------|
| `OUTPUT_TRACE_HTTP_VERBOSE` | Set to `true` or `1` to include request/response headers and bodies in trace files (otherwise only method, URL, and status are recorded) |

## API Reference

For complete TypeScript API documentation, see the [HTTP Module API Reference](https://output-ai-reference-code-docs.onrender.com/modules/http_src.html).
