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_atiscompleted_atplus 7 days, but the API setscompleted_atat your first poll after the job finished. After a late first poll,urls_expire_atis later than the real expiry, andurls_expiredcan be missing on links that have already expired.