Research preview. Apply for access. We review each application and email you when it's approved, with your API key ready on the API keys page.

Bulk harvest

A bulk harvest job captures residual-stream activations for a list of prompts and writes them to safetensors files that you download by URL. You can send up to 4,096 prompts in one job. Use bulk harvest for work on a whole corpus, such as collecting training data for sparse auto-encoders or building a large probing dataset.

To get one prompt's activations in the same response as its completion, or to capture a steered generation, use inline capture instead. Layer k is the residual stream after decoder block k, the same numbering that inline capture and steering use. Each job runs on a separate harvest app for the model, not on the engines that serve completions.

Submit a job

Send your prompts to POST https://infra.acsresearch.org/v1/harvest:

curl -s "$ACS_API_BASE/harvest" \
  -H "Authorization: Bearer $ACS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-8b",
    "prompts": ["The quick brown fox jumps over the lazy dog.",
                "Interpretability research reads the residual stream."],
    "layers": [8, 16, 24]
  }'

The API answers 202 with these five keys:

{ "job_id": "071fa547-…", "status": "running", "model": "llama-8b", "n_prompts": 2, "reserved_tokens": 21 }

status is running if the harvest service accepts the job within 60 seconds, and pending if it does not.

reserved_tokens is the prompt-token count the job holds against your key monthly, account monthly, monthly input, and daily budgets. When the job finishes, the API charges the harvester's token count instead, but a job that fails releases its reserved tokens.

Poll the job

Poll GET https://infra.acsresearch.org/v1/harvest/<job_id> once per minute until status is done, failed, or cancelled, because the API has no webhook. Any key on your account can poll or cancel the job.

curl -s "$ACS_API_BASE/harvest/071fa547-…" -H "Authorization: Bearer $ACS_API_KEY"
{
  "job_id": "071fa547-…",
  "status": "done",
  "model": "llama-8b",
  "n_prompts": 2,
  "result": {
    "n_shards": 1,
    "layer_indices": [8, 16, 24],
    "manifest_url": "https://…/manifest.json?…",      // presigned, no key needed
    "shard_urls":   ["https://…/shard_00000.safetensors?…"],
    "stats_url":    "https://…/stats.safetensors?…",
    "timings": { "forward_s": 1.59, "upload_s": 4.05, "tokens": 21, /* … */ }
  },
  "urls_expire_at": "2026-07-24T20:31:37+00:00"
}

The example omits some response keys. To learn sooner that the job has ended, use a long poll.

Cancel a job

DELETE https://infra.acsresearch.org/v1/harvest/<job_id> cancels a pending or running job.

curl -s -X DELETE "$ACS_API_BASE/harvest/9b3e61c2-…" \
  -H "Authorization: Bearer $ACS_API_KEY"
{
  "job_id": "9b3e61c2-…",
  "status": "cancelled",
  "model": "llama-8b",
  "n_prompts": 2,
  "created_at": "2026-08-07T20:14:00+00:00",
  "completed_at": "2026-08-07T20:14:12+00:00"
}

The example leaves out run_id. A 404 means your account has no job with that id, and a 409 harvest_not_cancellable means the job already ended.

Cancelling frees the job's concurrency slot before the 200 returns, but if you cancel a job after it started, you pay its reserved tokens.

Download and read the shards

The URLs in result are presigned and do not need an API key. Download the files within 7 days of the job finishing.

import os, requests
from safetensors.torch import load_file

API_BASE = os.environ["ACS_API_BASE"]
KEY = os.environ["ACS_API_KEY"]

job = requests.get(f"{API_BASE}/harvest/{job_id}",
                   headers={"Authorization": f"Bearer {KEY}"}).json()

open("shard0.safetensors", "wb").write(requests.get(job["result"]["shard_urls"][0]).content)
shard = load_file("shard0.safetensors")

shard["prompt_0"]   # bf16, [n_layers_kept, n_tokens, hidden], for example [3, 11, 4096]
shard["tokens_0"]   # int32, [n_tokens], same order as the tensor's token axis

In prompt_i and tokens_i, i is the prompt's index in your prompts list.

Avoid a double BOS

By default, harvest prepends the model's BOS token, <|begin_of_text|> on Llama, to each prompt. If your text already starts with BOS, as a chat template rendered on the client does, the job gets two BOS tokens. Every position is then off by one, and the API returns no error.

A length difference of one between tokens_i and your own tokenization, with a repeated first id, means a double BOS. To fix it, set "add_special_tokens": false.

Read the manifest and statistics

manifest_url points to a JSON index with these main keys, shown without values:

{
  "dtype": "...",
  "add_special_tokens": ...,
  "layer_indices": [...],
  "residual_stream_convention": "...",
  "shards": [
    {
      "key": "...",
      "prompt_indices": [...],
      "tensors": [
        { "name": "prompt_0", "shape": [...] },
        { "name": "tokens_0", "shape": [...] }
      ]
    }
  ],
  "contents": "activations",
  "statistics": "...",
  "mean_token_norm": { "<layer>": ... }
}

The manifest's mean_token_norm is the mean L2 norm of the residual vectors per layer. stats_url points to a small safetensors file with layer_indices and the per-layer token mean and std over the whole run.

The statistics and mean_token_norm leave out position 0 of every prompt, because a BOS token there has outlier norms that would skew them. With the default add_special_tokens, position 0 is the BOS token, but with add_special_tokens: false, position 0 is your first real token.

Get projections instead of activations

To store each token's projection onto directions such as probe vectors or SAE features, send them as project_onto:

{
  "model": "llama-8b",
  "prompts": ["…"],
  "layers": [14],
  "project_onto": {                 // shape (n_directions, hidden)
    "data": "<base64>",             // raw little-endian buffer, no compression
    "dtype": "float32",             // or float16 / bfloat16
    "shape": [8, 4096],
    "compression": "none"
  }
}

prompt_i then has dimensions [n_layers_kept, n_tokens, n_directions] in float32, and tokens_i does not change. With 8 directions on llama-8b, the activation tensor is 256 times smaller than in a raw shard.

The harvester L2-normalizes each direction, so each value is the component along a unit direction. Without that step, a direction that is not unit length would scale every value by its length, and nothing in the output would show it.

Whatever dtype you sent, the manifest records the projection as float32 in these keys:

{
  "dtype": "float32",
  "contents": "projections",
  "projection": { "n_directions": ..., "normalized": true, "note": "..." }
}

stats.safetensors, mean_token_norm, and the manifest's statistics string then describe the projections. mean and std have dimensions [n_layers_kept, n_directions].

A direction without the model's hidden size fails the job before the model loads.

Reference

Request fields

An unknown field name returns 400.

Field Type Behavior
model string The value is the model ID, such as llama-8b.
prompts list of non-empty strings Each prompt is a text string, and the API does not accept token ids.
layers list of integers, "all", or omitted Without the field, harvest keeps the quartile layers [n//4, n//2, 3n//4], which are [8, 16, 24] on llama-8b. "all" keeps every layer. "ALL" and "All" return 400. The shards contain only these layers. Each layer keeps the number it has in inline capture.
shard_size integer The number sets how many prompts go into each output file. The default is 32 prompts.
batch_size integer The harvester batches prompts on its own and ignores this field.
add_special_tokens boolean The default is true. With false, harvest does not add a BOS token.
project_onto codec object The object describes a 2D (n_directions, hidden) buffer with 1 to 64 directions. It must use compression: "none" and a dtype of float32, float16, or bfloat16. A buffer that is not n_directions * hidden * itemsize bytes returns 400 at submit, with the byte counts in the message.

Response keys

Response Keys
200 from GET or DELETE job_id, status, model, run_id, n_prompts, created_at, completed_at
Added for a done job result, urls_expire_at, accounting, and urls_expired once the URLs have expired
Added for a failed job error
Inside result n_shards, layer_indices, manifest_url, shard_urls, stats_url, timings, and bookkeeping keys such as run_id, n_prompts, batch_size, and uploaded

We keep the model loaded for up to 20 minutes after a job, so a later job in that window can reuse it and report timings.load_s near 0.

Limits and errors

Condition Response
The request has over 4,096 prompts or over 2,000,000 tokens. For this limit, the service estimates the token count as the number of characters divided by 4. 400
The request body is over 50 MiB, which is 52,428,800 bytes. 413
The layers list includes a layer that the model does not have. 400
A prompt is longer than the context window. The harvester keeps the first max_model_len - 1 tokens. tokens_i shows the tokens that ran.
Your account runs 3 llama-8b jobs, or it runs 1 job on llama-405b or trinity-truebase. The llama-8b limit and the large-model limit count separately, across all your account's keys. 429 harvest_concurrency_exceeded with Retry-After: 30. The message states which limit is full.
We already run our limit of large-model jobs across all accounts, which is 2 by default. 429 harvest_capacity_exceeded with Retry-After: 30
The key has used its monthly job quota. We give a new key a monthly quota of 200 jobs. 429 harvest_quota_exceeded
The service is still counting tokens for another submission from your account, all counting slots are busy, or counting takes over 10 seconds. 429 harvest_count_busy with Retry-After. If your corpus always takes over 10 seconds to count, split it into smaller jobs.
Your token budget has run out. 429 budget_exceeded
The harvest app for the model is unreachable. 503 harvest_unavailable. The message includes the job id.

Long polls

Add ?wait= with a value from 0 to 60 seconds to keep the poll open until the job ends or the wait passes. Other values return 400. A long poll returns the same body as an immediate poll.

We allow 4 waiting polls per key and 8 in total per server process. If a long poll goes over either limit, the API answers within 3 seconds with a 200, the current status, X-Acs-Longpoll: declined, and Retry-After.

Known limits

  • The presigned URLs expire 7 days after the job finished. urls_expire_at is completed_at plus 7 days, but the API sets completed_at at your first poll after the job finished. After a late first poll, urls_expire_at is later than the real expiry, and urls_expired can be missing on links that have already expired.

Next steps