# Page contents

Canonical page: https://agentsky.dev/tools/exa/contents
JSON record: https://gateway.agentsky.dev/v1/endpoints/exa/contents

Page contents is a tool from Exa, callable through the AgentSky API as exa/contents and billed per call ($0.0012).

Given up to a hundred URLs, or document ids from an earlier search, returns readable extracted text rather than raw markup, served from cache or crawled fresh depending on the age tolerance given. Beyond text it can return query-relevant highlights with relevance scores, a written summary, and extras such as outbound links, images, and code blocks. A per-URL status array reports which pages were served from cache, which were crawled, and which failed, so a partial failure stays visible.

One entry per URL with its text, highlights, and summary, plus a status list saying how each page was obtained.

## Facts

- **Id:** `exa/contents`
- **Provider:** Exa
- **Kind:** tool
- **Billed per:** call
- **Timeout:** 5 min
- **Max request:** 1 MB

## Call it

`POST https://gateway.agentsky.dev/v1/run` with `{"provider": "exa", "endpoint": "contents", "input": {…}}` and `Authorization: Bearer $AGENTSKY_API_KEY`.

### Input fields

| Field | Type | Required | Description |
|---|---|---|---|
| `urls` | array | yes | The pages to fetch, up to 100. |
| `text` | object | no | Return extracted page text; the object form caps its length. |
| `highlights` | object | no | Return the passages most relevant to a query. |
| `summary` | object | no | Return a written summary of each page. |
| `maxAgeHours` | integer | no | How stale a cached copy may be; 0 forces a fresh crawl. |

## Example

### curl

```bash
# Set once per job; reuse this key and the identical body after a timeout.
: "${AGENTSKY_IDEMPOTENCY_KEY:?Set a unique, saved key for this job}"
curl --fail-with-body https://gateway.agentsky.dev/v1/run \
  -H "authorization: Bearer $AGENTSKY_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: $AGENTSKY_IDEMPOTENCY_KEY" \
  -d '{"provider":"exa","endpoint":"contents","input":{"urls":["https://example.com"],"text":{"maxCharacters":2000},"maxAgeHours":24}}'
# If status is READY/RUNNING, poll GET /v1/runs/{runId} with the same API key.
```

### JavaScript

```js
// Node.js 20+. Save one unique key per job; reuse it with the identical body.
const key = process.env.AGENTSKY_IDEMPOTENCY_KEY;
if (!key || !process.env.AGENTSKY_API_KEY) {
  throw new Error("Set AGENTSKY_API_KEY and a saved AGENTSKY_IDEMPOTENCY_KEY first");
}
const base = "https://gateway.agentsky.dev";
const headers = {
  authorization: `Bearer ${process.env.AGENTSKY_API_KEY}`,
  "content-type": "application/json",
  "Idempotency-Key": key,
};
const body = JSON.stringify({
  "provider": "exa",
  "endpoint": "contents",
  "input": {
    "urls": [
      "https://example.com"
    ],
    "text": {
      "maxCharacters": 2000
    },
    "maxAgeHours": 24
  }
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function request(path, options = {}) {
  for (let attempt = 0; attempt < 3; attempt++) {
    let response;
    let data;
    try {
      response = await fetch(base + path, {
        ...options, headers, signal: AbortSignal.timeout(180_000),
      });
      const text = await response.text();
      try { data = JSON.parse(text); } catch { /* Proxy timeouts may return HTML. */ }
    } catch (error) {
      if (attempt === 2) throw error; // Resume with the same key and body.
      await sleep(2000);
      continue;
    }
    // Failed runs can have non-2xx HTTP status. Never regenerate them automatically.
    if (data?.runId && data?.status) return data;
    if (response.ok && data) throw new Error("Unexpected response: missing runId/status");
    if (attempt < 2 && (response.status >= 500 || response.status === 408 || response.status === 429)) {
      await sleep(2000);
      continue; // POST retries keep the original key and body.
    }
    throw new Error(`HTTP ${response.status}: ${JSON.stringify(data ?? "Non-JSON response; retry with the same key and body")}`);
  }
}

let run = await request("/v1/run", { method: "POST", body });
console.log("Save runId:", run.runId);
const deadline = Date.now() + 30 * 60_000;
while (["READY", "RUNNING"].includes(run.status)) {
  if (Date.now() >= deadline) {
    throw new Error(`Still pending: poll /v1/runs/${run.runId} later; do not create a new job`);
  }
  await sleep(5000);
  run = await request(`/v1/runs/${encodeURIComponent(run.runId)}`);
}
console.log("Status:", run.status, "Charge:", run.price?.amount);
if (run.status !== "COMPLETED") {
  throw new Error(`Run ${run.runId}: ${JSON.stringify(run.error ?? run.status)}`);
}
console.log(run.output); // Output shape depends on the endpoint.
// READY/RUNNING price is provisional. Use the terminal receipt for reconciliation.
```

## Pricing

$0.0012 per call

Read the final customer charge from price.amount.value after the run finishes. A RUNNING response is not the final bill.

## Questions

### What does Page contents do?

Given up to a hundred URLs, or document ids from an earlier search, returns readable extracted text rather than raw markup, served from cache or crawled fresh depending on the age tolerance given. Beyond text it can return query-relevant highlights with relevance scores, a written summary, and extras such as outbound links, images, and code blocks. A per-URL status array reports which pages were served from cache, which were crawled, and which failed, so a partial failure stays visible.

### How much does Page contents cost?

$0.0012 per call, using your plan when signed in. The customer charge is returned as price.amount.value with its currency; READY/RUNNING values are provisional.

### What does Page contents take?

urls is required; the rest of the fields are optional. Send them in the input object of a POST to /v1/run.

### What are the limits on Page contents?

The catalog lists an execution timeout of 5 min. A request can return READY or RUNNING before completion; poll GET /v1/runs/{runId} with the same API key until it reaches a terminal status. An HTTP timeout does not prove the generation stopped.

### How do I retry safely and check the final charge?

Save a unique Idempotency-Key for each job. After a network error or HTML timeout, resend the identical POST body with the same key and API key to recover the existing run, then poll its runId. A new key can start another billable generation. An HTTP error or timeout does not establish the run outcome or final charge. Pending runs can still settle; check the terminal price.amount and billing state, and retain the runId for reconciliation. Do not automatically create a new job after a terminal failure.
