Run Trawl in production.
Every job reaches a terminal state — the interesting operational questions are how long that takes, what retries on the way, and where to look when the answer is 'failed'. This chapter is the operator's view.
You should have jobs flowing (02 · Your first extraction) and, ideally, webhook delivery set up (04 · Webhooks). Everything here works over the HTTP API with a workspace key, or through the typed @productcraft/trawl client, which wraps the same endpoints.
1
The clocks every job runs on
A job carries its own wall-clock budget, and a background reaper guarantees nothing stays queued or running forever. Between them, every job terminates.
timeout_s is the budget you control. Set it per job at create time: 1 to 3,600 seconds, default 120. It bounds the extraction run itself — an agent that hasn't finished inside the budget ends as failed, with either extraction did not complete within the step/time budget or extraction timed out after <ms>ms depending on where the clock caught it. (There is also a server-side step budget — each agent step is roughly one model call — so timeout_s and page count are the levers you size; see 03 · Schema design for sizing guidance.)
The reaper is the safety net. A background sweep runs every 5 minutes and force-fails jobs that can no longer produce a result:
- A
runningjob is failed oncestarted_atis older thantimeout_splus a 60-second grace period — the grace covers a result that's in flight or a worker restart, so a job finishing right at the buzzer isn't clobbered. - A
queuedjob that no worker ever picked up is failed after 30 minutes.
Reaped jobs come out looking like any other failure: status failed, completed_at stamped, and a distinctive error string —
{
"id": "9f6f2f4e-...",
"status": "failed",
"result": null,
"error": "job exceeded its time budget (reaped)",
"attempts": 1,
"enqueued_at": "2026-07-10T09:00:00.000Z",
"started_at": "2026-07-10T09:00:04.000Z",
"completed_at": "2026-07-10T09:07:12.000Z"
}A reap sends job.failed to your webhook endpoints like any other failure — your receiver doesn't need a special case.
Worst-case time to a terminal state (useful for setting your own alerting thresholds): for a running job whose worker died, timeout_s + 60 s grace + up to 5 minutes of reaper cadence. For a queued job that was never picked up, 30 minutes + up to 5 minutes. If a job is still non-terminal well past those bounds, that's worth a support ticket — it should be impossible.
2
What retries — and what doesn't
Knowing where the platform retries for you (and where it deliberately doesn't) tells you exactly what your side needs to handle.
- Extraction: one run per job. A job is executed once; there are no automatic re-runs of a failed extraction. The
attemptsfield on the job is an informational counter, not a retry knob. To try again, create a new job — ideally with the failure's lesson applied (looser schema, better seeds, biggertimeout_s). - Transient model-provider errors: retried in place. Inside a run, transient 429/5xx errors from the model provider are retried up to 4 times with backoff. You never see these; they just consume budget.
- Webhook delivery: retried automatically for about 33 hours. A failed delivery is recorded in the event log (section 5), counts toward auto-disable, and is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (seven attempts in all, same
webhook-id). If every attempt fails, nothing is lost: the result lives on the job row. PollGET .../jobs/:idto recover. - Messaging-layer redelivery: rare duplicates possible. Delivery is at-least-once end to end — a retry, or a crash on our side mid-dispatch, can deliver the same event twice. Dedupe on
X-Trawl-Event-Id. - Your own retries of the create call: use
Idempotency-Key. ReplayingPOST .../jobswith the same key and body returns the original response (with headerIdempotent-Replay: true) for 24 hours instead of enqueuing a second job; the same key with a different body is rejected with409.
3
Submission limits
Two per-workspace limits on job creation. Reads are unmetered.
- 10 job creates per minute per workspace. The 11th in a fixed one-minute window gets
429with"code": "RATE_LIMITED"and aRetry-Afterheader (seconds until the window resets). Sleep forRetry-Afterand retry — that's the whole handling story. - 25 jobs queued or running at once. Over that, creates get
429with"code": "TOO_MANY_ACTIVE_JOBS"and noRetry-After— it clears when your jobs finish, not on a clock. Cancel or wait.
Rejected requests don't consume quota (validation runs first), and an idempotent replay short-circuits before the limiter — so retrying a create with the same Idempotency-Key is free. Reads (GET jobs, webhooks, events) are not rate limited today; poll on a sensible interval anyway.
4
Observing failures: the job list
The job row is the primary observability surface — status, error string, and timing fields are all on it. Start every investigation here.
curl 'https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs?status=failed&limit=20' \
-H 'authorization: Bearer pcft_live_...'The list is newest-first with cursor pagination (limit 1–100, default 20; pass cursor from pagination.next_cursor while pagination.has_more is true). The status filter accepts queued, running, succeeded, failed, and cancelled. Listing needs the trawl.list action; reading a single job needs trawl.read.
On a failed job, the error string is the diagnosis. The common ones — and what to do about each — are in the troubleshooting matrix below.
5
Observing failures: the webhook event log
When the question is 'did my endpoint get called, and what did it say back?', the event log has the answer.
curl 'https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/webhooks/events?endpoint_id=<endpoint_id>&status=failed' \
-H 'authorization: Bearer pcft_live_...'{
"data": [
{
"id": "evt_c7e9b1d3-...",
"type": "job.failed",
"created_at": "2026-10-17T09:07:13.000Z",
"status": "pending",
"deliveries": [
{
"endpoint_id": "d2a4e6f8-...",
"attempt_number": 1,
"status_code": 500,
"succeeded": false,
"attempted_at": "2026-10-17T09:07:13.000Z",
"next_attempt_at": "2026-10-17T09:08:13.000Z"
}
]
}
],
"pagination": { "next_cursor": null, "has_more": false }
}Events of the last 30 days, newest first (requires trawl.webhook.read). GET .../webhooks/events/:event_id lists every attempt. How to read an attempt:
status_codeisnullwhen the request never got a response — DNS failure, TLS failure, the 15-second delivery timeout, or a URL our delivery guard refused (delivery requires an HTTPS URL resolving to a public address; private, loopback, link-local, and cloud-metadata targets are rejected). In those caseserror_messagesays why.response_body_truncatedholds up to the first 1,024 bytes of your endpoint's response — enough to see your own error message.- Success is any 2xx; anything else is a recorded failure.
Auto-disable. Failures accumulate per endpoint: after 20 consecutive failures, or once a failure streak has lasted 24 hours, the endpoint is switched off (status becomes auto_disabled) and its queued retries are dropped. Any 2xx along the way resets the streak.
Recovery: fix your endpoint, switch it back on, prove it with a test, then replay what it missed:
curl -X PATCH https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/webhooks/endpoints/<endpoint_id> \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "enabled": true }'
curl -X POST https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/webhooks/endpoints/<endpoint_id>/test \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "event_type": "job.succeeded" }'
curl -X POST 'https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/webhooks/events/<event_id>/replay?endpoint_id=<endpoint_id>' \
-H 'authorization: Bearer pcft_live_...'Switching on clears the failure streak (requires trawl.webhook.manage). The test returns what your server answered. The results are also still on the job rows, so you can reconcile by listing jobs that completed during the outage instead.
6
Cancel semantics
Cancellation is a bookkeeping operation, not a kill switch. Know what it does — and doesn't — stop.
curl -X POST https://api.trawl.productcraft.co/v1/workspaces/<workspace_id>/jobs/<job_id>/cancel \
-H 'authorization: Bearer pcft_live_...'- Requires the
trawl.cancelaction. Onlyqueuedandrunningjobs can be cancelled; a job that has already reachedsucceeded,failed, orcancelledis immutable and the cancel is rejected with422. An unknown id (or a job in another workspace) returns404. - On success you get the job back with status
cancelledandcompleted_atstamped. Treat any2xxas success — the endpoint answers201today. - Cancellation doesn't interrupt a running extraction. It marks the job cancelled; a worker already browsing keeps going until its own budget, and the late result is dropped rather than written back. Practical upshot: cancelling a
runningjob stops you from seeing the result, not from the work happening — so cancel to clean up your own pipeline, not to save budget. - Cancellation fires no webhook event. Only
job.succeededandjob.failedexist — if your pipeline tracks cancelled jobs, track them from the API response or by polling, not from webhooks.
7
Troubleshooting matrix
Symptom → diagnosis → fix, for the failures you'll actually see.
- Job stuck in
queued. Workers pull jobs from a queue; under load, queued time is normal. A job that is never picked up is failed by the reaper after 30 minutes withjob exceeded its time budget (reaped)— so “stuck forever” can't happen. If you see queued jobs regularly aging toward that ceiling, that's a platform-side capacity signal — contact support rather than re-submitting the same job repeatedly. - Failed with
extraction did not complete within the step/time budget(orextraction timed out after <ms>ms). The agent ran out of time or steps. Unreachable pages are a common cause — the agent sees a navigation error and tries other sources, which burns budget; if every source is dead or the information genuinely isn't findable, the budget runs out. Fix: verify yoursuggested_urlsresolve, raisetimeout_sfor multi-page jobs, or narrow the ask. - Failed with
agent could not complete the extraction. The agent finished but judged that it couldn't actually satisfy the request — it never ships a “successful” result it doesn't stand behind. Usually the data isn't on the public web in the shape you asked for. Make hard-to-find fields nullable (03 · Schema design) so a partial answer is a legal answer. - Blocked or bot-walled pages. Trawl doesn't do anything special to defeat anti-bot walls. A blocked page behaves like any other unreachable page: the agent typically routes around it to other sources, or the job ends in one of the budget failures above. If the only source for your data aggressively blocks automation, Trawl is the wrong tool for that source. Note also that pages behind a login are out of scope — Trawl reads the public web only.
- Failed with
result failed schema validationorinvalid json_schema: …. Schema problems, not operations problems: the first means the agent's output couldn't satisfy your constraints; the second means the stored schema used a construct the converter can't handle. 03 · Schema design covers both, including the keyword set to stay inside. - “Empty” extraction — success, but fields are
null. Working as designed: the agent is instructed to returnnullrather than invent a value it couldn't find. Lots of nulls means the description didn't point at where the data lives, or the data isn't public. Sharpen the description, addsuggested_urls, and treat persistent nulls on a field as a signal to drop it. - Webhook silent while jobs are completing. Check the endpoint first: if its
statusisauto_disabled, it was switched off after a failure streak. Also check itsevent_typesinclude the event you expect. Fix your endpoint,PATCH { "enabled": true }, thenPOST .../testto confirm — see section 5. - 403 on every call:
trawl is not enabled on this workspace. The workspace doesn't have thetrawlservice enabled — enable it in the console under Workspace → Services → Trawl → Enable. A 403 naming a specific action (e.g.Policy denies trawl.create on *) means your key or role lacks that permission instead. - 429 on create:
RATE_LIMITEDorTOO_MANY_ACTIVE_JOBS. You hit a submission limit — see section 3. HonourRetry-Afterwhen it's present; otherwise wait for in-flight jobs to finish.
8
Latency intuition
What you can plan around, and what you have to measure.
- Duration scales with pages: every extra source the agent visits costs extra steps, and each step is roughly one model call. The default 120-second budget suits a job that reads one to three pages; research-style jobs that aggregate across many sources need a larger
timeout_s(up to 3,600). - Good
suggested_urlsshorten runs — without seeds the agent spends steps searching for sources before it extracts anything. - Measure your own workloads:
enqueued_at,started_at, andcompleted_atare on every job row, so queue wait and run duration are one subtraction each. Alert on the worst-case bounds from section 1, not on averages.
What's next
You've finished the series
From here:
- API reference for every endpoint, field, and status code.
- Concepts for the mental model behind jobs, schemas, and webhooks.
- Revisit 04 · Webhooks when you harden your receiver — signature verification and event dedupe live there.