@outcrawl/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1677 @@
1
+ /**
2
+ * Outcrawl wire contract.
3
+ *
4
+ * Every surface — SDK, REST API, MCP, CLI — speaks these shapes. The API is the
5
+ * truth; the other three are transports. See `registry.ts` for the capability
6
+ * list they are all generated or checked against.
7
+ *
8
+ * Three rules from the product surface spec are enforced *by omission* here,
9
+ * and all three are load-bearing:
10
+ *
11
+ * 1. The caller never chooses identity, seed, fingerprint, timezone,
12
+ * languages or webrtcIp. We mint them. Anything configurable is
13
+ * eventually configured wrong, and an incoherent identity is what gets
14
+ * caught — not any single value.
15
+ * 2. The caller never *sees* identity either. `Profile` and `SessionQuery`
16
+ * deliberately have no `identity` field. Customers see outcomes; we see
17
+ * which identity produced them.
18
+ * 3. The caller never chooses a model, and never learns which one ran.
19
+ * `AgentRequest` has no `model` field: it names a harness — `scout-1` or
20
+ * `voyager-1`, see `agent-alias.ts` — and which models that harness runs,
21
+ * including on a mid-run failover, is ours. A published per-minute price
22
+ * and a caller-chosen model cannot coexist; measured on one real
23
+ * production run the cheapest and dearest credible drivers differ by 126x,
24
+ * and whoever picks absorbs that. So the caller picks the capability. No
25
+ * response type, no usage row, no cache reason and no error message in
26
+ * this file carries a model id.
27
+ */
28
+ import { type AgentAlias } from './agent-alias.js';
29
+ /**
30
+ * Which browser an execution presents as. Minted by us at
31
+ * `Target.createBrowserContext`, pinned for the life of a profile, and NEVER
32
+ * exposed to customers — it appears in no request and no response type in this
33
+ * file. Operator-side only.
34
+ */
35
+ export type Identity = 'chrome' | 'brave';
36
+ /**
37
+ * Proxy targeting is country/region/city ONLY. No ASN, no ZIP, no ISP —
38
+ * geography is a legitimate product choice, the rest is a way to build an
39
+ * identity that cannot exist.
40
+ */
41
+ export interface ExitTarget {
42
+ country: string;
43
+ region?: string;
44
+ city?: string;
45
+ }
46
+ /**
47
+ * A concrete exit, resolved at session open. Session-scoped, not
48
+ * profile-scoped: a profile pins the city, the IP rotates underneath it.
49
+ */
50
+ export interface ResolvedExit {
51
+ ip: string;
52
+ country: string;
53
+ region: string;
54
+ city: string;
55
+ /** IANA zone, from the city table. Never client-supplied. */
56
+ timezone: string;
57
+ /** Single tag for chrome identity, e.g. "en-US". */
58
+ languages: string;
59
+ lat: number;
60
+ lon: number;
61
+ }
62
+ /**
63
+ * An exit may be named as a structured target or as a proxy URL of the form
64
+ * `outcrawl://<country>[/<region>[/<city>]]`, e.g. `outcrawl://us/texas/austin`.
65
+ */
66
+ export type ExitSpec = ExitTarget | string;
67
+ /**
68
+ * Counters, all integers. These are safe as `number`: token counts and
69
+ * millisecond timings sit well inside binary64's exact-integer range.
70
+ *
71
+ * The meter does not. `UsageEventRow.quantity` in `@outcrawl/persistence` is an
72
+ * exact decimal string, and a surface that aggregates usage_events into this
73
+ * shape is crossing string -> number. That direction is fine. **The reverse
74
+ * never is**: append the string the meter produced, never a number that has
75
+ * been through binary64 and back. Money and meters are not floats, and a
76
+ * rounding error found in a reconciliation months later is unattributable.
77
+ */
78
+ export interface Usage {
79
+ inputTokens?: number;
80
+ outputTokens?: number;
81
+ reasoningTokens?: number;
82
+ cachedInputTokens?: number;
83
+ inferenceTimeMs?: number;
84
+ cache?: {
85
+ status: 'HIT' | 'MISS' | 'DISABLED';
86
+ reason?: string;
87
+ };
88
+ }
89
+ /** Resources that actually appear on the bill. Charged, not estimated. */
90
+ export interface BilledResources {
91
+ browserMinutes: number;
92
+ bandwidthBytes: number;
93
+ /** Decimal string, e.g. "2.00". Never a float — money is not binary64. */
94
+ credits: string;
95
+ }
96
+ /** A JSON Schema object. Drives `json` extraction and agent success detection. */
97
+ export type JsonSchema = {
98
+ readonly [key: string]: unknown;
99
+ };
100
+ /** ISO 8601 instant, e.g. "2026-08-29T11:04:00.000Z". */
101
+ export type Timestamp = string;
102
+ /**
103
+ * Frame-qualified node reference: `<frameOrdinal>-<backendNodeId>`.
104
+ *
105
+ * Backend node ids come from a per-renderer-process static map, and
106
+ * cross-origin iframes run in different renderer processes under Site
107
+ * Isolation, so a bare backendNodeId can collide across frames.
108
+ */
109
+ export type NodeRef = `${number}-${number}`;
110
+ /**
111
+ * The de facto vocabulary. `html` is post-transform, `rawHtml` is
112
+ * pre-transform, and `json` cascades — it needs markdown first. That ordering
113
+ * is why this is one pipeline and not five.
114
+ */
115
+ export declare const SCRAPE_FORMATS: readonly ["markdown", "html", "rawHtml", "links", "screenshot", "screenshot@fullPage", "json", "changeTracking"];
116
+ export type ScrapeFormat = (typeof SCRAPE_FORMATS)[number];
117
+ export declare function isScrapeFormat(value: unknown): value is ScrapeFormat;
118
+ export interface ScrapeRequest {
119
+ url: string;
120
+ /** Defaults to `['markdown']`. */
121
+ formats?: readonly ScrapeFormat[];
122
+ /** Defaults to true — strip nav, chrome and boilerplate. */
123
+ onlyMainContent?: boolean;
124
+ /** Required when `formats` includes `json`. */
125
+ schema?: JsonSchema;
126
+ /** Pinned identity and logged-in state. */
127
+ profile?: string;
128
+ /** Geography only. Ignored when `profile` is given — a profile pins its exit. */
129
+ exit?: ExitSpec;
130
+ /** Extra settle time after the content classifier fires, in milliseconds. */
131
+ waitFor?: number;
132
+ timeoutMs?: number;
133
+ /** Compared against a previous snapshot when `changeTracking` is requested. */
134
+ changeTrackingTag?: string;
135
+ }
136
+ /**
137
+ * How a captcha was resolved on a page.
138
+ *
139
+ * `abandoned` is the load-bearing value: the solve outran its budget, so the
140
+ * document in hand is the interstitial and NOT the page. A caller that treats
141
+ * it as content is parsing a challenge screen. `not-attempted` means solving
142
+ * was deliberately never started because the request's own timeout could not
143
+ * accommodate one — buying a token and then discarding it is worse than not
144
+ * buying it.
145
+ */
146
+ export type PageChallengeOutcome = 'solved' | 'unsolved' | 'not-attempted' | 'abandoned';
147
+ export interface PageChallenge {
148
+ readonly outcome: PageChallengeOutcome;
149
+ /** Vendor as the browser named it, e.g. `recaptcha`. */
150
+ readonly detector: string;
151
+ readonly elapsedMs: number;
152
+ /** Solve budget this page reserved. 0 means solving was not armed. */
153
+ readonly budgetMs: number;
154
+ }
155
+ /**
156
+ * Whether this page was WATCHED for a challenge at all, which is what makes an
157
+ * absent {@link PageMetadata.challenge} interpretable.
158
+ *
159
+ * IT EXISTS BECAUSE THE ABSENCE WAS AMBIGUOUS AND SILENT. `challenge` is
160
+ * populated only from an armed solve window, and `@outcrawl/render`'s
161
+ * `unarmedWindow` settles `null` on purpose — with no policy in force the
162
+ * browser inspects nothing, so reporting a challenge would be inventing an
163
+ * observation. Correct, and it left every unwatched page indistinguishable
164
+ * from a page that genuinely had no captcha: HTTP 200, a full charge, the
165
+ * interstitial as `markdown`, and no field saying so.
166
+ *
167
+ * `PipelineTrace` already carried the numbers that resolve it and already
168
+ * argued the case — "without this number a page that was deliberately left
169
+ * unsolvable and a page that simply had no captcha are indistinguishable to an
170
+ * operator". That is the same ambiguity, and it was closed for an OPERATOR
171
+ * while the CUSTOMER kept it. This field is that fact on the surface the
172
+ * customer reads.
173
+ *
174
+ * `armed` — a solve window was open. An absent `challenge` then means there
175
+ * was no challenge, and that is a statement rather than a silence.
176
+ *
177
+ * `unarmed` — no window. Either this worker is not solving at all
178
+ * (`--challenge-mode=ignore`, or no provider key), or the request's own
179
+ * `timeoutMs` could not accommodate a solve. An absent `challenge` here means
180
+ * WE DO NOT KNOW, and a challenged page is exactly what it looks like.
181
+ *
182
+ * `refused` — a window was asked for and the browser declined the policy. Our
183
+ * fleet's fault rather than the caller's, distinguished from `unarmed` because
184
+ * "we chose not to look" and "we tried to look and could not" are different
185
+ * facts and only one of them is actionable by the customer.
186
+ */
187
+ export type ChallengeWatch = 'armed' | 'unarmed' | 'refused';
188
+ export interface PageMetadata {
189
+ title?: string;
190
+ description?: string;
191
+ /** Content language as declared by the page, not by us. */
192
+ language?: string;
193
+ contentType?: string;
194
+ scrapedAt: Timestamp;
195
+ /**
196
+ * True when the cheap stop-early path was not enough and the page was given
197
+ * time to run its scripts. Every request leaves through the browser's own
198
+ * network stack either way — the saving is in stopping early, never in
199
+ * skipping the browser.
200
+ */
201
+ escalated: boolean;
202
+ /**
203
+ * How a captcha on this page was resolved. Absent when none was OBSERVED,
204
+ * which is not the same as none having been raised — read
205
+ * {@link PageMetadata.challengeWatch} to tell those apart.
206
+ */
207
+ challenge?: PageChallenge;
208
+ /**
209
+ * Whether a challenge could have been observed on this page at all.
210
+ *
211
+ * **Required, with no `?`.** An optional field here would reintroduce the
212
+ * exact defect it closes: a consumer cannot tell an absent watch state from
213
+ * an unwatched page, and this repository has already shipped six seams whose
214
+ * omission was read as a decision.
215
+ */
216
+ challengeWatch: ChallengeWatch;
217
+ }
218
+ export type ChangeStatus = 'same' | 'changed' | 'new' | 'removed';
219
+ export interface ChangeTracking {
220
+ status: ChangeStatus;
221
+ previousScrapedAt?: Timestamp;
222
+ /** Unified diff over the normalised DOM, present only when status is `changed`. */
223
+ diff?: string;
224
+ /** Model summary of the diff — the diff, never the whole page. */
225
+ summary?: string;
226
+ }
227
+ export interface ScrapeResult {
228
+ /** Final URL after redirects. */
229
+ url: string;
230
+ statusCode: number;
231
+ markdown?: string;
232
+ html?: string;
233
+ rawHtml?: string;
234
+ links?: string[];
235
+ /**
236
+ * The capture itself, base64 PNG, as `Page.captureScreenshot` returned it.
237
+ * Both `screenshot` and `screenshot@fullPage` land here.
238
+ *
239
+ * NOT A URL, and this comment said "Fetchable URL" until it was measured.
240
+ * Live `POST /v1/scrape` against prod on 2026-09-06 returned 610,368 bytes
241
+ * of PNG in this field — `89 50 4e 47`, 1200x841, 8-bit RGB — because
242
+ * `renderFormats` puts `PipelineDeps.screenshot`'s reply here unchanged and
243
+ * nothing between it and the wire uploads anything. A client that wrote
244
+ * `fetch(result.screenshot)` on the strength of the old sentence would send
245
+ * a 610 kB request line.
246
+ */
247
+ screenshot?: string;
248
+ /** Present when `json` was requested; shaped by the supplied schema. */
249
+ json?: unknown;
250
+ changeTracking?: ChangeTracking;
251
+ metadata: PageMetadata;
252
+ usage: Usage;
253
+ }
254
+ export interface CrawlRequest {
255
+ url: string;
256
+ /** Hard ceiling on pages fetched. */
257
+ limit?: number;
258
+ maxDepth?: number;
259
+ includePaths?: readonly string[];
260
+ excludePaths?: readonly string[];
261
+ allowSubdomains?: boolean;
262
+ allowExternalLinks?: boolean;
263
+ /** Skip sitemap.xml discovery and crawl the link graph only. */
264
+ ignoreSitemap?: boolean;
265
+ formats?: readonly ScrapeFormat[];
266
+ onlyMainContent?: boolean;
267
+ schema?: JsonSchema;
268
+ /** One warm context for the whole crawl: one coherent identity, not a thousand. */
269
+ profile?: string;
270
+ exit?: ExitSpec;
271
+ /** Delivered per page as the crawl streams; results are never batched. */
272
+ webhook?: string;
273
+ }
274
+ /** A crawled page: a scrape result plus its position in the frontier. */
275
+ export interface CrawlPage extends ScrapeResult {
276
+ depth: number;
277
+ /** The page whose link graph produced this URL; absent for seeds and sitemap entries. */
278
+ discoveredFrom?: string;
279
+ }
280
+ export type JobStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
281
+ /**
282
+ * One URL a crawl could not fetch. Produced by the scheduler in
283
+ * `@outcrawl/render`, which imports this rather than declaring its own — the
284
+ * producer owns the behaviour, core owns the wire shape.
285
+ */
286
+ export interface CrawlFailure {
287
+ url: string;
288
+ depth: number;
289
+ message: string;
290
+ }
291
+ export interface CrawlJob {
292
+ id: string;
293
+ status: JobStatus;
294
+ url: string;
295
+ /** Pages completed so far. Crawls stream, so this moves. */
296
+ completed: number;
297
+ /** Pages currently known to the frontier — an estimate until the crawl ends. */
298
+ discovered: number;
299
+ createdAt: Timestamp;
300
+ /**
301
+ * Per-URL failures. Present and populated on runs that otherwise succeeded —
302
+ * a crawl that fetched 88 of 100 pages is a success with twelve failures, not
303
+ * a failed crawl, so an empty check here is not a success check.
304
+ *
305
+ * Declared because without it the information is simply lost at the wire: the
306
+ * scheduler collects the list, the closing frame is a `CrawlJob`, and a
307
+ * customer who hit twelve dead links would receive `completed: 88` with no
308
+ * way to learn which twelve.
309
+ */
310
+ failures?: readonly CrawlFailure[];
311
+ usage: Usage;
312
+ }
313
+ export interface SearchRequest {
314
+ query: string;
315
+ limit?: number;
316
+ /** Render every hit through our pipeline. Without it this is a pass-through. */
317
+ scrape?: boolean;
318
+ formats?: readonly ScrapeFormat[];
319
+ /** Two-letter country bias for the index. */
320
+ country?: string;
321
+ lang?: string;
322
+ }
323
+ export interface SearchHit {
324
+ url: string;
325
+ title: string;
326
+ snippet: string;
327
+ /** 1-based position in the result set. */
328
+ rank: number;
329
+ /** Populated only when `scrape` was requested. */
330
+ markdown?: string;
331
+ html?: string;
332
+ rawHtml?: string;
333
+ links?: string[];
334
+ json?: unknown;
335
+ }
336
+ export interface SearchResponse {
337
+ hits: SearchHit[];
338
+ usage: Usage;
339
+ }
340
+ export declare const NOTIFY_ADAPTERS: readonly ["discord", "slack", "webhook"];
341
+ export type NotifyAdapter = (typeof NOTIFY_ADAPTERS)[number];
342
+ export declare function isNotifyAdapter(value: unknown): value is NotifyAdapter;
343
+ interface NotifyCommon {
344
+ /** Shapes the payload the adapter emits. Discord embed and Slack Block Kit otherwise. */
345
+ schema?: JsonSchema;
346
+ }
347
+ /**
348
+ * Exactly one adapter per monitor. A union rather than a bag of optionals, so
349
+ * a monitor that notifies nowhere cannot be constructed.
350
+ */
351
+ export type MonitorNotify = (NotifyCommon & {
352
+ discord: string;
353
+ }) | (NotifyCommon & {
354
+ slack: string;
355
+ }) | (NotifyCommon & {
356
+ webhook: string;
357
+ });
358
+ /**
359
+ * Normalisation is where these products live or die. A monitor that fires on a
360
+ * rotating ad or a timestamp is worthless, so the defaults are deliberately
361
+ * lossy.
362
+ */
363
+ export interface MonitorNormalisation {
364
+ /** Default true. */
365
+ stripTimestamps?: boolean;
366
+ /** Default true. CSRF tokens, cache-busters, build hashes. */
367
+ stripNonces?: boolean;
368
+ /** Default true. */
369
+ stripAds?: boolean;
370
+ /** Default true. */
371
+ stripSessionIds?: boolean;
372
+ /** Default true — sibling reordering is not a change. */
373
+ ignoreOrdering?: boolean;
374
+ /** Subtrees excluded from the diff entirely. */
375
+ ignoreSelectors?: readonly string[];
376
+ }
377
+ export interface MonitorSpec {
378
+ url: string;
379
+ /** Cron expression, e.g. "0 9 * * *". */
380
+ schedule: string;
381
+ notify: MonitorNotify;
382
+ /** Optional human label; the URL is the identity. */
383
+ label?: string;
384
+ formats?: readonly ScrapeFormat[];
385
+ profile?: string;
386
+ exit?: ExitSpec;
387
+ /** Run a model over the diff — the diff, not the page. Default false. */
388
+ judge?: boolean;
389
+ normalise?: MonitorNormalisation;
390
+ /** Default true. */
391
+ enabled?: boolean;
392
+ }
393
+ export interface Monitor extends MonitorSpec {
394
+ id: string;
395
+ createdAt: Timestamp;
396
+ lastCheckedAt?: Timestamp;
397
+ lastStatus?: ChangeStatus;
398
+ }
399
+ export interface MonitorCheck {
400
+ monitorId: string;
401
+ checkedAt: Timestamp;
402
+ status: ChangeStatus;
403
+ /** DOM tree diff, not a text diff. Present only when status is `changed`. */
404
+ diff?: string;
405
+ summary?: string;
406
+ /** The replay covering this check. */
407
+ replayUrl?: string;
408
+ usage: Usage;
409
+ }
410
+ export interface MonitorQuery {
411
+ url?: string;
412
+ /** Glob, e.g. "pricing-*". */
413
+ label?: string;
414
+ enabled?: boolean;
415
+ limit?: number;
416
+ cursor?: string;
417
+ }
418
+ export declare const CAP_NAMES: readonly ["steps", "budget", "duration"];
419
+ export type CapName = (typeof CAP_NAMES)[number];
420
+ /**
421
+ * Required on every agent run. The customer sets a ceiling before spending
422
+ * rather than disputing a bill afterwards, and since failures cost more than
423
+ * successes the same cap protects our margin.
424
+ */
425
+ export interface AgentCaps {
426
+ /** Hard stop; the task then fails cleanly with partial data attached. */
427
+ steps: number;
428
+ /** Credits as a decimal string, e.g. "2.00". Not steps, not tokens. */
429
+ budget: string;
430
+ /**
431
+ * Wall clock: `<n>ms` | `<n>s` | `<n>m` | `<n>h`, e.g. "5m".
432
+ *
433
+ * **There is no ceiling above this and there is deliberately no longer one.**
434
+ * A run is a job rather than a held request, so nothing in the platform cuts
435
+ * it off at a number the caller did not choose: `caps` is the customer's dial
436
+ * and hours are normal. The `h` unit stopped being decorative on the day the
437
+ * job model landed.
438
+ *
439
+ * What used to live here was `MAX_RUN_DURATION_MS`, a 15-minute refusal that
440
+ * read as a product limit and was nothing of the kind — it was the edge's
441
+ * `AbortSignal.timeout` on one forwarded `fetch`, quoted back to customers as
442
+ * policy. Deleting the held request deleted the ceiling with it; it was never
443
+ * tuned, because there was nothing to tune.
444
+ */
445
+ duration: string;
446
+ }
447
+ export interface AgentRequest {
448
+ task: string;
449
+ /** Required, not optional. See `AgentCaps`. */
450
+ caps: AgentCaps;
451
+ /** Supplies identity and logged-in state. */
452
+ profile?: string;
453
+ /**
454
+ * When supplied, schema satisfaction *is* the success signal, and
455
+ * {@link AgentResult.data} comes back schema-shaped.
456
+ *
457
+ * Optional, and omitting it no longer costs you the answer: a schema-less run
458
+ * returns the agent's own closing summary in `data` instead. It is still the
459
+ * better input when you need named fields, because a schema is also what the
460
+ * run is judged and held against.
461
+ */
462
+ schema?: JsonSchema;
463
+ /**
464
+ * Which harness runs this task. Absent means {@link DEFAULT_AGENT_ALIAS}.
465
+ *
466
+ * Replaces a `model?: string` field that documented itself as "`'default'`
467
+ * for the model we host, or a BYOK model id" and was consulted by exactly one
468
+ * function, whose only behaviour was to refuse every value but `'default'`.
469
+ * BYOK was never implemented and there is no route for a customer credential
470
+ * to reach a worker, so the field's whole effect was to publish a choice
471
+ * nobody had.
472
+ *
473
+ * The two names are capabilities, not model tiers: `scout-1` does about an
474
+ * hour of work on the web; `voyager-1` reasons harder over hours or days. See
475
+ * `agent-alias.ts` for what each runs and why the caller is not told.
476
+ */
477
+ agent?: AgentAlias;
478
+ /**
479
+ * Where to POST this run's terminal event, so nothing has to poll.
480
+ *
481
+ * Held at the EDGE for the life of the run and never handed to the machine
482
+ * running it: a fleet Mac is the least-trusted tier in this system, and a
483
+ * customer's webhook URL is a credential — whoever holds it can post a
484
+ * convincing "your run finished" into whatever the customer built on top of
485
+ * it. `server/src/notify.ts` renders the body, the same builder monitors
486
+ * already use.
487
+ *
488
+ * The field predates the job model by months and was read by NOTHING: it
489
+ * documented itself as "async delivery; the call returns an id immediately"
490
+ * on a route that returned an `AgentResult` fifteen minutes later. It is real
491
+ * now.
492
+ */
493
+ webhook?: string;
494
+ /**
495
+ * How much to push, and optionally where.
496
+ *
497
+ * The level is the addition: {@link webhook} above says WHERE and has never
498
+ * said HOW MUCH, so the only thing it could ever deliver was the terminal
499
+ * outcome. `notify.level` is what turns one POST at the end into the run's
500
+ * log as it happens — `step` for what the agent did, `debug` for its
501
+ * reasoning too, `silent` for a run that must not push at all even though a
502
+ * webhook is configured.
503
+ *
504
+ * `notify.webhook` overrides {@link webhook} when both are present, being the
505
+ * more specific statement. Neither is forwarded to the machine running the
506
+ * run and neither is ever the machine's to deliver: the push is a projection
507
+ * of durable `run_records` rows made at the CONTROL PLANE, which is what
508
+ * makes a failed POST retryable from the row and a dying browser unable to
509
+ * lose an event. See {@link NOTIFY_KINDS}.
510
+ */
511
+ notify?: NotifyPolicy;
512
+ exit?: ExitSpec;
513
+ /**
514
+ * Action cache, on by default. Key is URL + instruction + AX-tree hash +
515
+ * options; a hit replays with zero model tokens.
516
+ */
517
+ cache?: boolean;
518
+ /**
519
+ * Set `false` to decline the recording. Absent means RECORDED.
520
+ *
521
+ * Recording an agent run is automatic and this is the only way out of it.
522
+ * The direction is the whole contract: an absent field, a `true`, and any
523
+ * value this type does not admit all record, and nothing but an explicit
524
+ * `false` declines. A caller with a policy reason not to have a tape — a
525
+ * regulated tenant, a session touching personal data, a compliance boundary
526
+ * — had no way to say so before this field, and the only workaround was not
527
+ * using the product.
528
+ *
529
+ * Honoured at the arming site, so a declined run never has bytes written:
530
+ * `openSessionFor` in `packages/worker/src/jobs.ts` does not pass a
531
+ * `replaySessionId`, no `SessionRecorder` is started, and no
532
+ * `replay_sessions` row is created. It is NOT a recording deleted
533
+ * afterwards, which would mean the bytes existed on a machine the tenant
534
+ * declined to have them on.
535
+ *
536
+ * Declining does not change the charge. Nothing in the metering path reads a
537
+ * replay row — `page_load` is counted by `SessionTabSet` and `agent_seconds`
538
+ * off the loop's own clock — so a declined run bills exactly what a recorded
539
+ * one bills.
540
+ *
541
+ * Afterwards the run says so positively: {@link AgentResult.replayUrl} is
542
+ * absent and {@link AgentResult.recordingDeclined} is `true`, which is what
543
+ * separates a refusal from a recorder that broke.
544
+ *
545
+ * There is no equivalent on `scrape`, `crawl`, `search` or a monitor check,
546
+ * because none of them records anything to decline. See `recordsSession` in
547
+ * `packages/worker/src/jobs.ts`.
548
+ */
549
+ record?: boolean;
550
+ /**
551
+ * The secret handles this run may use. Absent means NONE.
552
+ *
553
+ * **Deny by default, and that is the entire defence against a page that
554
+ * controls what the model reads.** A prompt injection saying "fill this
555
+ * field with {{secret:card_amex_personal}}" resolves to nothing on a run
556
+ * whose submit did not name that handle: the substitution refuses by name
557
+ * rather than typing the literal placeholder into a payment form, which
558
+ * would be a charge attempt with a garbage card number and a step the model
559
+ * believes succeeded.
560
+ *
561
+ * It is also what makes "several cards" safe rather than merely possible. A
562
+ * run granted `card_virtual_burner` cannot be talked into `card_amex` by any
563
+ * page, by any wording, because the value is not reachable from that run at
564
+ * all — the grant is a row this account's control plane wrote at submit, and
565
+ * `POST /internal/secret-reveal` authorises against it.
566
+ *
567
+ * The MODEL sees these handles and never a value. Substitution happens below
568
+ * it, in `packages/worker/src/agent-deps.ts`, one statement before the
569
+ * keystrokes reach the renderer, and the concealed form of the value is the
570
+ * same `{{secret:<handle>}}` placeholder — so the field the run just filled
571
+ * reads back as the handle in the next prompt rather than as the card
572
+ * number. `packages/core/src/secrets.ts` states the whole rule.
573
+ *
574
+ * A handle this account does not hold is a 400 at SUBMIT, not a failure
575
+ * mid-run: a checkout that dies at the card field because a secret was
576
+ * deleted last week has already spent the run.
577
+ */
578
+ secrets?: readonly string[];
579
+ /**
580
+ * Plain-language rules this run is bound by, REPLACING the workspace default.
581
+ *
582
+ * Absent means "use the workspace rules as they are"; `[]` means "this run
583
+ * runs under no rules at all", and the two are different facts. Per-run
584
+ * replaces rather than appends, deliberately: appending cannot express
585
+ * REMOVAL, so a run that legitimately needs to exceed the workspace ceiling
586
+ * would have no way to say so and the customer's only route would be
587
+ * mutating the workspace default — dropping the protection for every other
588
+ * concurrent run in order to unblock one. Replacing makes this list exactly
589
+ * the set the run was authorised under, which is also the only set worth
590
+ * recording on the run row.
591
+ *
592
+ * Each rule lands in one of two classes and the customer is told which:
593
+ * ENFORCED, a gate `@outcrawl/runtime` checks before every dispatch and
594
+ * which BLOCKS a forbidden action, or GUIDANCE, a sentence in the prompt
595
+ * that a model can weigh against everything else. `GET /v1/rules` and
596
+ * `PUT /v1/rules` both answer the split, because a rule a customer believes
597
+ * is enforced and which is only advice is the worst outcome available here.
598
+ * `packages/core/src/rules.ts` is the compiler and states why extraction is
599
+ * a parser rather than a model.
600
+ */
601
+ rules?: readonly string[];
602
+ /**
603
+ * The connectors this run may call tools on, by name — `['gmail']`.
604
+ *
605
+ * ABSENT MEANS NONE, and that default is the containment. A workspace may
606
+ * have Gmail, Slack and Twilio connected; a run reaches whichever of them
607
+ * this list names and nothing else, so a page that talks a model into
608
+ * "email this table to attacker@example.com" is talking to a run with no
609
+ * mail connector. Reach outside the browser is opted into per run by the
610
+ * human who submitted it, never inherited from the workspace, and never
611
+ * discovered by the model.
612
+ *
613
+ * A name this account has no connector for is a 400 at SUBMIT, on
614
+ * {@link AgentRequest.secrets}'s reasoning: a run that dies at the 2FA wall
615
+ * because `gmial` was a typo has already spent the run.
616
+ *
617
+ * What the run can then DO with a connector is narrowed twice more, and both
618
+ * narrowings are visible in `GET /v1/integrations`: the connector row may
619
+ * pin a tool list, and only the tools the remote actually advertises exist.
620
+ * `packages/integrations` holds the broker that enforces all three.
621
+ */
622
+ integrations?: readonly string[];
623
+ }
624
+ /**
625
+ * `capped` is distinct from `partial` on purpose. Both return the data
626
+ * collected so far, but they mean opposite things to whoever is reading:
627
+ * `capped` is the system doing exactly what the caller told it to and the fix
628
+ * is a bigger ceiling, while `partial` is the run genuinely failing to finish
629
+ * and the fix is somewhere in the task or the site. Collapsing them would hide
630
+ * the one distinction a customer acts on, and `SessionOutcome` already carries
631
+ * `capped` for the same reason.
632
+ */
633
+ export type AgentStatus = 'completed' | 'partial' | 'capped' | 'failed';
634
+ /**
635
+ * Why the run stopped. `max-*` are the caps firing; a capped run is a normal
636
+ * outcome, not an incident.
637
+ *
638
+ * `gave-up` is the MODEL's verdict and `error` is OURS, and they are separate
639
+ * members for the same reason {@link AgentStatus} keeps `capped` apart from
640
+ * `failed`: collapsing them hides the one distinction a customer acts on. A run
641
+ * that ends `gave-up` chose `{"type":"fail","reason":"..."}` — an action the
642
+ * agent loop advertises on every prompt — because the task could not be done on
643
+ * the page in front of it. A run that ends `error` hit something that went
644
+ * wrong on our side: a lost action-lock, a reference naming no node, a step
645
+ * that threw. One means look at the task, the other means look at us, and while
646
+ * they shared a string neither a dashboard nor a retry loop could tell which
647
+ * had happened — so a caller retried, forever, something that will never
648
+ * succeed.
649
+ *
650
+ * Neither is a status: {@link AgentStatus} still answers "did the caller get
651
+ * what they asked for" (`failed` with no data, `partial` with some), and this
652
+ * answers why not.
653
+ *
654
+ * `cancelled` and `irreversible` are the two that are neither ours nor the
655
+ * model's. `cancelled` is the CUSTOMER's: somebody called
656
+ * `POST /v1/agent/{id}/cancel` and the run settled where it stood. It is not
657
+ * `gave-up` (the model never said anything) and not `error` (nothing of ours
658
+ * broke), and a retry loop must be able to tell it from both — retrying a run
659
+ * a human just stopped is the one retry that is certainly wrong.
660
+ *
661
+ * `irreversible` is the HARNESS refusing rather than failing. A run is
662
+ * resumable until its first irreversible action — payment submitted, order
663
+ * placed, message sent, file handed to a third party, account created — and
664
+ * permanently un-resumable after it, because nothing can distinguish "the order
665
+ * went through and the confirmation page died with the browser" from "the order
666
+ * never went through". A resume past that point REPORTS what the run knew and
667
+ * does not act: no browser is opened, no step is dispatched, no credit is
668
+ * spent. Not `error` (nothing of ours broke) and not `gave-up` (the model never
669
+ * said anything) — the two it would otherwise be collapsed into, and a caller's
670
+ * retry loop must be able to tell "never retry this" from both.
671
+ */
672
+ export type AgentStopReason = 'schema-satisfied' | 'task-complete' | 'max-steps' | 'max-budget' | 'max-duration' | 'loop-detected' | 'gave-up' | 'irreversible' | 'cancelled' | 'error';
673
+ /** An action a page could take, resolved but not executed. Also the cache unit. */
674
+ export interface ObservedAction {
675
+ ref: NodeRef;
676
+ description: string;
677
+ /** e.g. "click", "fill", "selectOption". */
678
+ method: string;
679
+ args?: readonly string[];
680
+ /** Heal path: survives a node being genuinely recreated, which the ref cannot. */
681
+ xpath: string;
682
+ }
683
+ export interface AgentStep {
684
+ /** 0-based. */
685
+ index: number;
686
+ action: ObservedAction;
687
+ /** The model's stated reason for the action, when the model produced one. */
688
+ reasoning?: string;
689
+ at: Timestamp;
690
+ /**
691
+ * The tab this step ran on, on a multi-tab run. Absent means single-tab.
692
+ *
693
+ * On the wire because two consumers outside the loop cannot do their job
694
+ * without it. A replay player has nothing else to tell tab B's mutations from
695
+ * tab A's — every tab's renderer claims main-frame ordinal 0, so an
696
+ * un-namespaced recording applies one tab's changes to another's tree and
697
+ * plays back a run that never happened. And a scorer reading these frames
698
+ * infers which origin a step acted on from the last URL it saw; once focus can
699
+ * move between tabs, that inference is wrong without this field, and wrong
700
+ * quietly, which is worse than absent.
701
+ */
702
+ tab?: string;
703
+ /**
704
+ * The sub-goal this step was working, on a run that stated a plan. Absent
705
+ * when the run has no plan, or for the steps before one was stated.
706
+ *
707
+ * On the wire for the reason `tab` above is: a consumer scoring a run's
708
+ * progress cannot attribute a step to an outcome without it, and the runtime
709
+ * recorded it on `StepTrace.goal` all along while this projection dropped it.
710
+ */
711
+ goal?: string;
712
+ usage: Usage;
713
+ }
714
+ /**
715
+ * A sub-goal of a run, and what became of it.
716
+ *
717
+ * On the wire because a five-part task is five completions and one report, and
718
+ * `status` carries only the report: a run that did four of five errands and a
719
+ * run that did one are both `partial`, and the difference is the thing the
720
+ * customer is paying for.
721
+ *
722
+ * It is also the only way anything outside the loop can measure long-horizon
723
+ * behaviour. The runtime held all of this on `AgentResult.goals` and
724
+ * `StepTrace.goal` and the projection dropped both, so a plan-driven run and a
725
+ * planless one were byte-identical on the wire — which meant the harness that
726
+ * exists to score long-horizon runs scored them the same, and an absence read
727
+ * off that output could not distinguish "no plan was stated" from "plans do not
728
+ * appear here". One such absence was reported as evidence during this work.
729
+ *
730
+ * Optional, and absent rather than `[]` when the run never stated a plan: an
731
+ * empty array reads as "asked and answered nothing", and absent reads as "this
732
+ * run had no plan", which is the true statement.
733
+ */
734
+ export interface AgentGoal {
735
+ id: string;
736
+ goal: string;
737
+ /** Origin hint, when the plan named one. */
738
+ site?: string;
739
+ status: 'pending' | 'active' | 'done' | 'failed';
740
+ /** What closed it: the confirmation on `done`, the reason on `failed`. */
741
+ summary?: string;
742
+ /** The step it closed on. Absent while still open. */
743
+ closedAtStep?: number;
744
+ }
745
+ export interface AgentResult {
746
+ id: string;
747
+ /** Published success rates are 42–71%, so `partial` is normal, not an error. */
748
+ status: AgentStatus;
749
+ /**
750
+ * The run's answer. Partial results are returned, never discarded.
751
+ *
752
+ * WITH a schema this is schema-shaped, and a partially satisfied schema still
753
+ * comes back. WITHOUT one it is the agent's closing summary of what it found,
754
+ * so a question asked without a schema is still answered.
755
+ *
756
+ * OPTIONAL, and absence is not an error: it means the run had nothing to
757
+ * report — "click the unsubscribe link" asks no question — and such a run is
758
+ * still `completed`. Absent rather than `null` deliberately: `null` is a
759
+ * value a schema'd extraction can legitimately produce, and overloading it
760
+ * would make "the answer is null" and "there was no answer" the same reading.
761
+ *
762
+ * This field was declared required while the API shipped responses without
763
+ * it, because nothing populated it on a schema-less run and `JSON.stringify`
764
+ * erases an `undefined` value.
765
+ */
766
+ data?: unknown;
767
+ usage: Usage;
768
+ /**
769
+ * How long the run took, from the loop's own monotonic clock.
770
+ *
771
+ * **This is a billed quantity, and that is why it is on the wire.** Agent is
772
+ * sold at +5 credits per minute; the API meters `agent_seconds` from exactly
773
+ * this number, and billing a customer per minute while refusing to tell them
774
+ * the minutes is indefensible. It is also the reading `AgentCaps.duration`
775
+ * was enforced against, so a `capped` run with `stopReason: 'max-duration'`
776
+ * can be checked against the ceiling that stopped it.
777
+ *
778
+ * Not optional. `SessionSummary.durationMs` is required for the same reason,
779
+ * and this field existed on the runtime's own result all along —
780
+ * `toWireResult` simply dropped it, which is how the published per-minute
781
+ * price came to have no minute anywhere on the customer's side of the wire.
782
+ */
783
+ durationMs: number;
784
+ /**
785
+ * Watch exactly what the money bought. This is how a charge is disputed.
786
+ *
787
+ * OPTIONAL, and the absence is a statement rather than a gap: this run was
788
+ * not recorded anywhere durable. It was required until now and it lied —
789
+ * every agent run this product completed returned a link of the shape
790
+ * `https://outcrawl.ai/replay/r/<id>`, and `replay_sessions` held zero rows
791
+ * from the day the table was created, because a fleet Mac has no database
792
+ * credential and so bound an in-memory replay store that was freed when the
793
+ * process exited.
794
+ *
795
+ * A host that cannot honour a link now answers `null` from
796
+ * `ReplayStore.replayUrl` and this field is spread away — see
797
+ * `packages/worker/src/main.ts` `replayStore()`, whose memory branch says why
798
+ * refusing to BOOT over a missing replay credential would be an outage caused
799
+ * by replay. An absent field reads as "this run was not recorded"; a dead
800
+ * link reads as "your product is broken", and a customer chasing the second
801
+ * one opens a support ticket about our routing.
802
+ *
803
+ * `MonitorCheck.replayUrl` above has been optional since it was written, for
804
+ * exactly this reason and left deliberately unpopulated. That is the shape
805
+ * this codebase already chose once.
806
+ *
807
+ * PRESENT means a recording EXISTS, including one still sealing. The link is
808
+ * gated on `sessions.replay_key`, which is written the moment the recorder
809
+ * arms and never on `replay_sessions.state`, so a run whose bytes are still
810
+ * being flushed carries its url — that is what makes a live follow work.
811
+ * ABSENT therefore means no recording, and {@link recordingDeclined} is what
812
+ * says which of the two ways it came to have none.
813
+ */
814
+ replayUrl?: string;
815
+ /**
816
+ * The CALLER declined the recording, with `AgentRequest.record: false`.
817
+ *
818
+ * Present and `true` only on a refusal; absent otherwise. It exists because
819
+ * an absent {@link replayUrl} had two causes and no way to tell them apart:
820
+ * a tenant who refused a tape, and a recorder that was asked for one and
821
+ * could not deliver — a sink that declined, a browser that would not arm, a
822
+ * host booted with no replay credential. Those are a satisfied request and
823
+ * an incident, and a customer disputing a charge, an auditor checking a
824
+ * compliance boundary and an operator reading a session with no tape all
825
+ * need the difference. This repo has shipped that same silence before, where
826
+ * a `replayUrl` derived from the row id alone made "recorded" and "never
827
+ * recorded" identical on the wire.
828
+ *
829
+ * So the three states are: `replayUrl` present — there is a recording;
830
+ * `replayUrl` absent with this `true` — the caller refused one and no bytes
831
+ * were ever written; `replayUrl` absent with this field absent — one was
832
+ * wanted and not obtained, which is ours to fix.
833
+ *
834
+ * TYPED `true` AND NOT `boolean`. A `recordingDeclined: false` on the wire
835
+ * would be a claim about a decision on every result that ever returns, where
836
+ * the absence already says everything — the argument {@link replayUrl} makes
837
+ * for being spread rather than assigned. It also means the default cannot be
838
+ * spelled: there is no `false` to write, so nothing can express "declined
839
+ * unless told otherwise".
840
+ */
841
+ recordingDeclined?: true;
842
+ stepsUsed: number;
843
+ /**
844
+ * Navigations committed over the run. A billed quantity, metered as
845
+ * `page_load` at the same per-page rate a scrape pays.
846
+ *
847
+ * **Required, with no `?`.** The rate card sells agent minutes on top of the
848
+ * run's pages and browser time, and with no page count on this object the API
849
+ * metered a page residual of zero against runs that demonstrably navigated —
850
+ * the same defect `durationMs` above had, and an optional field would have
851
+ * reproduced it silently, because zero pages reads as a legal invoice rather
852
+ * than a missing one.
853
+ *
854
+ * Counted in `@outcrawl/runtime` from observed per-tab URL changes, so a click
855
+ * that navigates counts and switching back to an already-loaded tab does not.
856
+ * A revisit counts again: five sites with one revisit is six pages, which is
857
+ * the only reading that does not price a multi-site agent run below six
858
+ * scrapes of the same six pages.
859
+ */
860
+ pages: number;
861
+ stopReason: AgentStopReason;
862
+ /** The plan and its outcomes. Absent when the run never stated one. See {@link AgentGoal}. */
863
+ goals?: readonly AgentGoal[];
864
+ /** Present when status is `failed`. */
865
+ error?: string;
866
+ }
867
+ /**
868
+ * Where a run is, at the instant it was read.
869
+ *
870
+ * Exactly {@link AgentStatus} plus the three states a job has and a request
871
+ * never did. `queued` is accepted-and-not-yet-placed; `running` is on a
872
+ * machine; `paused` is on a machine and WAITING ON A HUMAN. Everything else is
873
+ * terminal and is the same vocabulary the finished result reports, so a poller
874
+ * and a webhook consumer read one union rather than two.
875
+ *
876
+ * **There is no `cancelled` here, deliberately.** A cancel is not an outcome:
877
+ * a run that had already found the answer when somebody stopped it is
878
+ * `completed` and its data is real. What a human asked for is
879
+ * {@link AgentRun.cancelRequestedAt} — an instant, written once — and why the
880
+ * run stopped is `stopReason: 'cancelled'`. Folding the request into the status
881
+ * would throw away a delivered answer to record a button press.
882
+ *
883
+ * **`paused` is ONE state for two doors, and it is not terminal.** A run parks
884
+ * either because a human took the wheel (`POST /v1/agent/{id}/control`) or
885
+ * because the agent asked a question (`ask`, answered by
886
+ * `POST /v1/agent/{id}/answer`), and which of the two it was lives on
887
+ * {@link AgentRun.pause} rather than in a second status: everything else about
888
+ * the state is identical, and a second status would double every predicate in
889
+ * the system in order to record which button was pressed. It is an OPEN state
890
+ * on the same terms as `queued` and `running` — no `endedAt`, no `stopReason`
891
+ * — because a park always has an exit: the answer, the handback, or the park's
892
+ * own deadline, after which the run resumes and is told nobody replied. A run
893
+ * cannot end `paused`, so no consumer needs to treat it as an outcome.
894
+ */
895
+ export type AgentRunStatus = 'queued' | 'running' | 'paused' | AgentStatus;
896
+ /**
897
+ * Whether this run can still do anything, as ONE predicate.
898
+ *
899
+ * It was spelled `status !== 'queued' && status !== 'running'` in three places
900
+ * — the event stream's `done` frame, the upload route's refusal, and the SDK's
901
+ * `settled()` poll — and every one of them means "has this run finished". A
902
+ * fourth state that three of those copies had never heard of is how a PARKED
903
+ * run comes to report `done: true` on its event stream, be refused an upload
904
+ * for having "ended", and settle an SDK poll with a run that is still waiting
905
+ * for the answer that poll was about to deliver.
906
+ *
907
+ * `paused` counts as OPEN, which is the whole reason this exists: a park is a
908
+ * run in the middle of its life, holding a warm browser, that is going to take
909
+ * more steps.
910
+ */
911
+ export declare function isRunOpen(status: AgentRunStatus): boolean;
912
+ /**
913
+ * What a run appended, and why.
914
+ *
915
+ * ONE log, one sequence space. `result` is the deliverable; the rest is how the
916
+ * run got there. They share a table and a `seq` because the alternative —
917
+ * results in one stream and progress in another — needs the two reconciled in
918
+ * front of a customer, and a step IS a record of what happened.
919
+ *
920
+ * `irreversible` is the one a resume reads: past it a run may report and may
921
+ * not act. See {@link AgentStopReason}. `question` is the one a PERSON reads:
922
+ * it is the whole discovery path for a parked run, which is why a question is
923
+ * a durable record rather than a field on the row — a customer who was not
924
+ * watching finds it in the same log they read for everything else, and it is
925
+ * pushed to their webhook by the same projection.
926
+ */
927
+ export declare const AGENT_RECORD_KINDS: readonly ["result", "finding", "ruled-out", "goal", "step", "irreversible", "question"];
928
+ export type AgentRecordKind = (typeof AGENT_RECORD_KINDS)[number];
929
+ /**
930
+ * How much of the log a subscriber wants.
931
+ *
932
+ * Three levels and not a kind list, because the caller who wants "just tell me
933
+ * what is happening" should not have to know the vocabulary — and because a
934
+ * level is stable across kinds we add later, where a hand-passed list silently
935
+ * stops including them.
936
+ */
937
+ export declare const AGENT_EVENT_LEVELS: readonly ["status", "step", "debug"];
938
+ export type AgentEventLevel = (typeof AGENT_EVENT_LEVELS)[number];
939
+ /** The default when `level` is not asked for: progress without the reasoning. */
940
+ export declare const DEFAULT_AGENT_EVENT_LEVEL: AgentEventLevel;
941
+ /**
942
+ * Which kinds each level carries. Declared HERE and read by both ends, because
943
+ * the edge decides what to select and the customer decides what to ask for, and
944
+ * a level that means two different things at those two places is a stream with
945
+ * holes in it that nothing reports.
946
+ */
947
+ export declare const AGENT_EVENT_KINDS: Readonly<Record<AgentEventLevel, readonly AgentRecordKind[]>>;
948
+ /**
949
+ * How much of a run is PUSHED to a customer's endpoint, as against how much a
950
+ * subscriber READS.
951
+ *
952
+ * ── Why this is not `AgentEventLevel`, and what is shared anyway ─────────────
953
+ *
954
+ * {@link AGENT_EVENT_LEVELS} is `status | step | debug` and spells exactly TWO
955
+ * of the four names below. `silent` has no meaning for a reader — a subscriber
956
+ * asking for nothing simply does not subscribe — and `error` has no meaning for
957
+ * a cursor tail, which is a query and not a policy. Going the other way,
958
+ * `status` is not a delivery level: it selects three kinds, and what a customer
959
+ * wants pushed at the quiet end is not three kinds, it is the ONE terminal fact
960
+ * that the run is over.
961
+ *
962
+ * So this is a second list of NAMES. It is deliberately not a second list of
963
+ * KINDS: {@link NOTIFY_KINDS} takes the `step` and `debug` sets by REFERENCE
964
+ * from {@link AGENT_EVENT_KINDS}, so a kind added to what `step` carries when
965
+ * read is carried by what `step` pushes in the same edit. Two overlapping
966
+ * vocabularies is where a drift bug lives; the overlap is therefore not
967
+ * retyped, it is shared.
968
+ */
969
+ export declare const NOTIFY_LEVELS: readonly ["silent", "error", "step", "debug"];
970
+ export type NotifyLevel = (typeof NOTIFY_LEVELS)[number];
971
+ export declare function isNotifyLevel(value: unknown): value is NotifyLevel;
972
+ /**
973
+ * The default when a run names no policy, and it is `error` for a compatibility
974
+ * reason rather than a taste one: {@link AgentRequest.webhook} already exists
975
+ * and a bare one has always meant "tell me when it is over". A field that
976
+ * already exists must not change meaning under the callers who already set it.
977
+ */
978
+ export declare const DEFAULT_NOTIFY_LEVEL: NotifyLevel;
979
+ /**
980
+ * Which record kinds each level pushes AS THEY BECOME DURABLE.
981
+ *
982
+ * `silent` and `error` push none: the difference between those two is the
983
+ * terminal outcome, which is not a record — see {@link pushesTerminal}.
984
+ */
985
+ export declare const NOTIFY_KINDS: Readonly<Record<NotifyLevel, readonly AgentRecordKind[]>>;
986
+ /**
987
+ * Whether the run's terminal outcome is pushed at this level.
988
+ *
989
+ * Everything but `silent`, and `error` in particular. A webhook that is silent
990
+ * on success was the other candidate reading of that word and it is refused
991
+ * here: it makes a working endpoint indistinguishable from a broken one, so the
992
+ * customer cannot tell "still running" from "finished fine" — which is the
993
+ * failing-state-renders-as-the-passing-state defect this whole field exists to
994
+ * remove. `error` means "one message, and what I act on is its `status`".
995
+ */
996
+ export declare function pushesTerminal(level: NotifyLevel): boolean;
997
+ /**
998
+ * Where a run's events go, and how many of them.
999
+ *
1000
+ * `level` is REQUIRED, so `notify: {}` is a 400 rather than a policy nobody
1001
+ * chose — the same rule {@link AgentCaps} follows and the same rule
1002
+ * `mcpTool: string | null` follows in the registry. `webhook` is optional
1003
+ * because {@link AgentRequest.webhook} may already carry it; when both are
1004
+ * present this one wins, being the more specific statement.
1005
+ */
1006
+ export interface NotifyPolicy {
1007
+ level: NotifyLevel;
1008
+ webhook?: string;
1009
+ }
1010
+ /**
1011
+ * One appended row.
1012
+ *
1013
+ * `seq` is allocated by the store, is gap-free per run and is the cursor: a
1014
+ * client that has seen `seq` asks for everything `after` it and cannot miss a
1015
+ * row, however many machines, retries or reconnections happened in between.
1016
+ * That property is the whole reason a killed browser does not cost a customer
1017
+ * their output.
1018
+ */
1019
+ export interface AgentRecord {
1020
+ seq: number;
1021
+ kind: AgentRecordKind;
1022
+ at: Timestamp;
1023
+ /**
1024
+ * The payload, shaped by `kind`: the extracted record for `result`, the
1025
+ * {@link AgentStep} for `step`, the {@link AgentGoal} for `goal`, and a
1026
+ * stated sentence for `finding`, `ruled-out` and `irreversible`.
1027
+ */
1028
+ value: unknown;
1029
+ /** The step this was produced at, when a step produced it. */
1030
+ step?: number;
1031
+ /** The sub-goal it belongs to, when the run had stated a plan. */
1032
+ goal?: string;
1033
+ }
1034
+ /**
1035
+ * One run, as `GET /v1/agent/{id}` answers it: status, plan, findings so far,
1036
+ * cost so far.
1037
+ *
1038
+ * Every quantity on it is CURRENT rather than final — `usage`, `credits`,
1039
+ * `stepsUsed`, `pages` and `records` all move while the run is live — because
1040
+ * every run bills for work performed and a customer must be able to watch the
1041
+ * meter rather than discover it.
1042
+ */
1043
+ export interface AgentRun {
1044
+ id: string;
1045
+ status: AgentRunStatus;
1046
+ task: string;
1047
+ /** The harness the run was placed on. Never the model behind it. */
1048
+ agent: AgentAlias;
1049
+ caps: AgentCaps;
1050
+ createdAt: Timestamp;
1051
+ /** Absent while `queued`: nothing has picked the run up yet. */
1052
+ startedAt?: Timestamp;
1053
+ /** Absent until terminal. */
1054
+ endedAt?: Timestamp;
1055
+ /** So far, from the loop's own monotonic clock. The billed quantity. */
1056
+ durationMs: number;
1057
+ stepsUsed: number;
1058
+ pages: number;
1059
+ /** Records of kind `result` appended so far. The count beside the output. */
1060
+ records: number;
1061
+ /** The high-water `seq` over EVERY kind — where to resume the event tail. */
1062
+ lastEventSeq: number;
1063
+ /** The plan as it currently stands. Absent when the run never stated one. */
1064
+ goals?: readonly AgentGoal[];
1065
+ usage: Usage;
1066
+ /** Credits spent so far, as a decimal string. What the bill is argued from. */
1067
+ credits: string;
1068
+ /**
1069
+ * The answer, inline, once there is one and it is small enough to be inline.
1070
+ * Above {@link INLINE_RESULT_BYTES} it is absent and {@link outputUrl} plus
1071
+ * {@link records} is the answer — asking for one title must never paginate,
1072
+ * and returning a megabyte on a status poll must never happen.
1073
+ */
1074
+ data?: unknown;
1075
+ /** Where the whole output is fetchable, when it did not fit inline. */
1076
+ outputUrl?: string;
1077
+ stopReason?: AgentStopReason;
1078
+ replayUrl?: string;
1079
+ recordingDeclined?: true;
1080
+ /** Present when `status` is `failed`. */
1081
+ error?: string;
1082
+ /**
1083
+ * When a human asked for this run to stop. Write-once: a second cancel cannot
1084
+ * move the instant, so "who stopped it and when" survives a retried request.
1085
+ * Present while the run is still settling as well as after — a cancel is
1086
+ * asked for, not applied instantly.
1087
+ */
1088
+ cancelRequestedAt?: Timestamp;
1089
+ /**
1090
+ * WHY THIS RUN IS PARKED, present exactly while `status` is `paused`.
1091
+ *
1092
+ * The one place the two doors are told apart. Everything else about a park
1093
+ * is identical — the row, the warm browser, the resume — so `reason` is a
1094
+ * field here rather than a second status, and a consumer that only cares
1095
+ * that the run is waiting can read `status` and stop.
1096
+ *
1097
+ * `expiresAt` is on it because a park a customer cannot see the end of is a
1098
+ * park they cannot plan around: it is when the run gives up waiting and
1099
+ * carries on, not when the run dies.
1100
+ */
1101
+ pause?: AgentPause;
1102
+ /**
1103
+ * When a human asked for the wheel, still unanswered by the machine.
1104
+ *
1105
+ * The same shape and the same reason as {@link cancelRequestedAt}: a park is
1106
+ * ASKED FOR at the edge and applied by the machine on its next report, so
1107
+ * there is an instant where the request exists and the park does not. Naming
1108
+ * that instant is what lets `POST /control` be idempotent and lets a
1109
+ * customer see that their request landed before the run has noticed it.
1110
+ */
1111
+ controlRequestedAt?: Timestamp;
1112
+ }
1113
+ /**
1114
+ * A park, as every surface reads it.
1115
+ *
1116
+ * ## Why a parked run is a ROW and not a held connection
1117
+ *
1118
+ * Because the thing being waited on is a person, and people are slower than
1119
+ * every timeout in this system. A park expressed as an open request would tie
1120
+ * the answer to one socket, one edge isolate and one machine staying up; a
1121
+ * park expressed as a row is discoverable by a customer who was asleep when it
1122
+ * happened, survives the process, and is answerable from a different device an
1123
+ * hour later. It is the same argument the job model already made for a run.
1124
+ */
1125
+ export interface AgentPause {
1126
+ /**
1127
+ * Which door the run went through.
1128
+ *
1129
+ * `control` — a human asked for the wheel; the browser is live and drivable
1130
+ * at {@link viewerUrl}, and the run resumes when they hand it back.
1131
+ * `question` — the agent asked something only the customer can answer, on
1132
+ * `POST /v1/agent/{id}/answer`.
1133
+ */
1134
+ reason: 'control' | 'question';
1135
+ at: Timestamp;
1136
+ /**
1137
+ * When the run stops waiting and carries on — NOT when it dies.
1138
+ *
1139
+ * A park has to expire, because it holds a warm browser and a machine slot
1140
+ * for somebody who may never reply. What expiry does is resume the run: an
1141
+ * unanswered question is answered "nobody replied" and the model decides,
1142
+ * and an unreturned wheel is taken back. So there is no state here a run can
1143
+ * be stuck in, and no stop reason that means "abandoned".
1144
+ */
1145
+ expiresAt: Timestamp;
1146
+ /** What the agent asked. Present exactly when `reason` is `question`. */
1147
+ question?: string;
1148
+ /** The replies the agent offered. Advisory: any answer is accepted. */
1149
+ options?: readonly string[];
1150
+ /** Who is driving. Present exactly when `reason` is `control`. */
1151
+ driver?: string;
1152
+ /**
1153
+ * Where a human goes to watch and drive.
1154
+ *
1155
+ * The durable `/play/{sessionId}` address and never a short-lived signed
1156
+ * token: this URL is put in front of a person, who will paste it into a chat
1157
+ * and open it twenty minutes later.
1158
+ */
1159
+ viewerUrl?: string;
1160
+ }
1161
+ /**
1162
+ * `POST /v1/agent/{id}/control` — take the wheel, or hand it back.
1163
+ *
1164
+ * ONE route and not two, because "take" and "release" are the same
1165
+ * transition read in opposite directions and a customer holding the wheel
1166
+ * needs exactly one place to look. `action` is required for the reason every
1167
+ * required-and-explicit field in this file is: a default here would be a
1168
+ * guess about whether a person wants control, and both guesses are bad.
1169
+ */
1170
+ export interface AgentControlRequest {
1171
+ action: 'take' | 'release';
1172
+ /**
1173
+ * Who is driving, for the audit trail and for the refusal message a second
1174
+ * driver gets. Defaulted to the calling API key's account when absent,
1175
+ * because the honest answer is never nobody.
1176
+ */
1177
+ who?: string;
1178
+ }
1179
+ export declare const AGENT_CONTROL_FIELDS: readonly ["action", "who"];
1180
+ /**
1181
+ * `POST /v1/agent/{id}/answer` — the reply to a question a run parked on.
1182
+ *
1183
+ * Free text, and deliberately not constrained to {@link AgentPause.options}: a
1184
+ * human who was offered two choices and needs a third has information the
1185
+ * model did not have, and the one seam that exists to bring a person's
1186
+ * judgment into a run must not refuse the judgment.
1187
+ */
1188
+ export interface AgentAnswerRequest {
1189
+ answer: string;
1190
+ answeredBy?: string;
1191
+ }
1192
+ export declare const AGENT_ANSWER_FIELDS: readonly ["answer", "answeredBy"];
1193
+ /**
1194
+ * Checks a control request. Whole-request validators live here, beside the
1195
+ * types and beside `validateAgentRequest`, so the edge and the machine cannot
1196
+ * disagree about what a valid body is.
1197
+ */
1198
+ export declare function validateAgentControlRequest(input: unknown): ValidationResult<AgentControlRequest>;
1199
+ /** Checks an answer. An empty answer is refused: it is a park that never ended. */
1200
+ export declare function validateAgentAnswerRequest(input: unknown): ValidationResult<AgentAnswerRequest>;
1201
+ /**
1202
+ * Above this, the answer does not ride inline on the run row.
1203
+ *
1204
+ * One megabyte, and it is a product decision rather than a database one: under
1205
+ * it a caller who asked for one title gets it in `data` with no second request,
1206
+ * and over it the records are in object storage and `data` would be a megabyte
1207
+ * on every status poll of a two-hour run.
1208
+ */
1209
+ export declare const INLINE_RESULT_BYTES = 1048576;
1210
+ /** What `POST /v1/agent` answers, in milliseconds. */
1211
+ export interface AgentSubmission {
1212
+ id: string;
1213
+ /** `queued`, always. Stated rather than implied so a client can assert it. */
1214
+ status: AgentRunStatus;
1215
+ createdAt: Timestamp;
1216
+ }
1217
+ /** `GET /v1/agent/{id}/events`. */
1218
+ export interface AgentEventQuery {
1219
+ /** Resume: everything with a greater `seq`. Absent starts at the beginning. */
1220
+ after?: number;
1221
+ level?: AgentEventLevel;
1222
+ limit?: number;
1223
+ }
1224
+ /** `GET /v1/agent/{id}/results`. */
1225
+ export interface AgentResultQuery {
1226
+ cursor?: string;
1227
+ limit?: number;
1228
+ }
1229
+ /**
1230
+ * A frame on the event channel.
1231
+ *
1232
+ * ── THE STREAM IS A PROJECTION OF DURABLE STATE, NOT A LIVE PIPE ─────────────
1233
+ *
1234
+ * Every frame here was a row before it was a frame. The channel reads them from
1235
+ * a cursor and ends; it is never attached to the browser, the machine or the
1236
+ * loop. Three things fall out of that and all three are load-bearing:
1237
+ *
1238
+ * * a stateless Worker can serve it, because there is nothing to hold;
1239
+ * * a dropped connection loses nothing — resume at `cursor`;
1240
+ * * **the browser can die and the stream cannot**, because the stream was
1241
+ * never attached to it. `docs/AGENTS.md` states that as a property of the
1242
+ * product; here it is a property of the transport.
1243
+ *
1244
+ * The next reader will want to "upgrade" this to a WebSocket. That would take
1245
+ * all three away in exchange for latency nobody asked for on a run measured in
1246
+ * hours.
1247
+ *
1248
+ * Tagged with `frame` rather than dispatched on shape. `crawl` sniffs its
1249
+ * header frame apart from its page frames and gets away with it because the two
1250
+ * cannot be confused; here a `record` carrying an arbitrary `value` could
1251
+ * legitimately look like anything, including a header.
1252
+ */
1253
+ export type AgentEventFrame =
1254
+ /**
1255
+ * First, always, before any record and even when there are none — so a client
1256
+ * that subscribed before the run produced anything receives the run and an
1257
+ * end rather than silence.
1258
+ */
1259
+ {
1260
+ readonly frame: 'run';
1261
+ readonly run: AgentRun;
1262
+ readonly level: AgentEventLevel;
1263
+ } | {
1264
+ readonly frame: 'record';
1265
+ readonly record: AgentRecord;
1266
+ }
1267
+ /**
1268
+ * Last, always. `cursor` is where to resume; `done` is whether resuming would
1269
+ * be pointless because the run is terminal and this was the end of its log.
1270
+ */
1271
+ | {
1272
+ readonly frame: 'end';
1273
+ readonly cursor: number;
1274
+ readonly done: boolean;
1275
+ };
1276
+ /**
1277
+ * A durable identity: minted seed, minted chrome-or-brave, pinned exit city
1278
+ * and its storage. Everything about it is minted and read-only — the caller
1279
+ * names it and nothing else.
1280
+ *
1281
+ * There is deliberately NO `identity` field, and there never will be.
1282
+ * Chrome-vs-brave is minted by us, pinned for the profile's life, and invisible
1283
+ * to customers; `seed`, `fingerprint`, `timezone`, `languages` and `webrtcIp`
1284
+ * are absent for the same reason. Every one of them is a way to build an
1285
+ * identity that cannot exist. The operator side runs the identity loop; the
1286
+ * customer side sees outcomes.
1287
+ */
1288
+ export interface Profile {
1289
+ id: string;
1290
+ label: string;
1291
+ /** Pinned exit affinity: country/region/city. The IP rotates underneath it. */
1292
+ exit: ExitTarget;
1293
+ createdAt: Timestamp;
1294
+ }
1295
+ export interface ProfileCreateRequest {
1296
+ label: string;
1297
+ /** Optional geography. Everything else about the profile is minted. */
1298
+ exit?: ExitTarget;
1299
+ }
1300
+ export interface ProfileQuery {
1301
+ /** Glob, e.g. "acme-*". */
1302
+ label?: string;
1303
+ limit?: number;
1304
+ cursor?: string;
1305
+ }
1306
+ export type SessionStatus = 'running' | 'completed' | 'partial' | 'failed';
1307
+ /** What actually happened, as opposed to how the call returned. */
1308
+ export type SessionOutcome = 'ok' | 'blocked' | 'timeout' | 'capped' | 'error';
1309
+ export interface SessionSummary {
1310
+ id: string;
1311
+ status: SessionStatus;
1312
+ outcome?: SessionOutcome;
1313
+ /** Absent for ephemeral sessions. */
1314
+ profileId?: string;
1315
+ /** The agent task, when the session was an agent run. */
1316
+ task?: string;
1317
+ /** Where this session actually came out. Session-scoped, not profile-scoped. */
1318
+ exit?: ResolvedExit;
1319
+ startedAt: Timestamp;
1320
+ endedAt?: Timestamp;
1321
+ durationMs: number;
1322
+ pages: number;
1323
+ /**
1324
+ * Hosted player. First-class, not a URL buried in an agent result.
1325
+ *
1326
+ * Optional for the reason {@link WireAgentResult.replayUrl} gives at length:
1327
+ * a session served by a host with no durable replay storage has no link that
1328
+ * will still resolve after that process exits, and an absent field is the
1329
+ * honest way to say so.
1330
+ */
1331
+ replayUrl?: string;
1332
+ /**
1333
+ * The caller declined a recording for this session. See
1334
+ * {@link AgentResult.recordingDeclined}, which is the same fact on the
1335
+ * result of the run that produced the row, argued at length.
1336
+ *
1337
+ * Read from `sessions.recording_declined`, which is written from the request
1338
+ * that opened the session and never moved afterwards. It is the reason
1339
+ * {@link replayUrl}'s absence is readable: a session with no url and this
1340
+ * `true` was refused, and one with no url and no field was wanted and not
1341
+ * obtained.
1342
+ *
1343
+ * Set for a `/connect` session and an `agent` run, the two capabilities that
1344
+ * record. Absent on a scrape, crawl, search or monitor check, which record
1345
+ * nothing and so have nothing to decline.
1346
+ */
1347
+ recordingDeclined?: true;
1348
+ usage: Usage;
1349
+ billed?: BilledResources;
1350
+ }
1351
+ /**
1352
+ * Note there is no `identity` filter here, by design. The equivalent internal
1353
+ * query — "every brave block this week" — lives on the operator side, where the
1354
+ * identity loop is run. Customers see outcomes; we see which identity produced
1355
+ * them.
1356
+ */
1357
+ export interface SessionQuery {
1358
+ status?: SessionStatus;
1359
+ outcome?: SessionOutcome;
1360
+ /** Glob against the agent task, e.g. "*checkout*". */
1361
+ task?: string;
1362
+ /** Relative (`24h`, `7d`) or an ISO instant. */
1363
+ since?: string;
1364
+ until?: string;
1365
+ profile?: string;
1366
+ limit?: number;
1367
+ cursor?: string;
1368
+ }
1369
+ export interface SessionExportRequest {
1370
+ sessionId: string;
1371
+ }
1372
+ export interface SessionExport {
1373
+ sessionId: string;
1374
+ /** Portable `.outcrawl` bundle; plays offline. */
1375
+ url: string;
1376
+ bytes: number;
1377
+ expiresAt: Timestamp;
1378
+ }
1379
+ export type UsageGrouping = 'hour' | 'day' | 'month' | 'capability';
1380
+ export interface UsageQuery {
1381
+ /** ISO date or instant. */
1382
+ from: string;
1383
+ to: string;
1384
+ /** Defaults to `day`. */
1385
+ groupBy?: UsageGrouping;
1386
+ }
1387
+ export interface UsageBucket {
1388
+ /** The day, hour, month or capability name this bucket covers. */
1389
+ key: string;
1390
+ usage: Usage;
1391
+ billed: BilledResources;
1392
+ }
1393
+ /**
1394
+ * Same numbers as the per-call `usage` fields, aggregated. Nothing appears here
1395
+ * that was not already visible on the call that caused it.
1396
+ */
1397
+ export interface UsageReport {
1398
+ from: string;
1399
+ to: string;
1400
+ groupBy: UsageGrouping;
1401
+ buckets: UsageBucket[];
1402
+ total: BilledResources;
1403
+ }
1404
+ /**
1405
+ * Why credits moved, in the vocabulary a customer is owed an answer in.
1406
+ *
1407
+ * Three values, and every one of them is reachable today: a student grant, a
1408
+ * refund for a run we charged for and should not have, and an operator
1409
+ * correction. There is deliberately no `expiry` and no `spend`.
1410
+ *
1411
+ * No `expiry`, because expiry is the entry's own `expires_at` — a column the
1412
+ * balance reader honours at read time — and not a row somebody has to remember
1413
+ * to write. A sweep that writes expiry rows is a sweep that can fail to run,
1414
+ * and the failure mode is a balance that stays spendable months after it
1415
+ * lapsed.
1416
+ *
1417
+ * No `spend`, because spend is metered usage and writing it here as well would
1418
+ * be two numbers for one fact with nothing holding them equal. `CREDIT_BALANCE`
1419
+ * in `@outcrawl/persistence` carries the whole argument.
1420
+ *
1421
+ * HERE rather than in `@outcrawl/persistence`, where it was, because three
1422
+ * packages now have to agree about it and none of them may hold a copy: the
1423
+ * ledger's `credit_ledger_reason_known` check constraint renders from it, the
1424
+ * router validates `POST /operator/credits` against it, and the CLI's
1425
+ * `--reason` flag offers it. A second list in any of the three is a value one
1426
+ * of them accepts and another refuses.
1427
+ */
1428
+ export type CreditReason = 'grant' | 'refund' | 'adjustment';
1429
+ export declare const CREDIT_REASONS: readonly ["grant", "refund", "adjustment"];
1430
+ /**
1431
+ * What authorised the entry, so a row can be traced back to the thing that
1432
+ * caused it rather than to whoever happened to run the insert.
1433
+ *
1434
+ * Paired with the entry's `reference_id` under a unique constraint, which is
1435
+ * what makes granting itself idempotent: a redeem code presented twice is
1436
+ * `('student_programme', <code id>)` twice and the second insert is a no-op,
1437
+ * not a second 10,000 credits.
1438
+ *
1439
+ * Narrow on purpose. `stripe` is absent because nothing in this tree talks to
1440
+ * Stripe, and a value that cannot be produced is not a source — it is an
1441
+ * unbilled intention every reader of the vocabulary has to rule out, which is
1442
+ * the exact defect `0007_usage_kinds.sql` removed from `UsageKind`.
1443
+ */
1444
+ export type CreditSource = 'student_programme' | 'promotion' | 'operator';
1445
+ export declare const CREDIT_SOURCES: readonly ["student_programme", "promotion", "operator"];
1446
+ /**
1447
+ * What an account still OWNS, as `GET /v1/credits` answers it.
1448
+ *
1449
+ * A different question from {@link UsageReport} and deliberately a different
1450
+ * route, because folding one into the other would break both. A usage report is
1451
+ * WINDOWED — it takes `from` and `to` and answers "what did this account
1452
+ * consume between these instants". This is a POOL: granted once, drawn down by
1453
+ * every credit ever spent against it, and not reset by a month boundary. A
1454
+ * balance shown inside a windowed report would read as that window's balance,
1455
+ * which is the misreading `CREDIT_BALANCE` in `@outcrawl/persistence` warns
1456
+ * about at length.
1457
+ *
1458
+ * It is also NOT the plan's monthly allowance. A paid tier's 5,000 credits a
1459
+ * month are an allowance that resets; these are credits the account was given
1460
+ * and keeps until they are spent or they lapse. The two are enforced and
1461
+ * reported by different machinery on purpose: a student's 10,000-credit grant
1462
+ * folded into the monthly window would silently lapse every month, which is
1463
+ * the opposite of what the promotion promises.
1464
+ *
1465
+ * Every figure is an exact decimal STRING, summed and subtracted by Postgres.
1466
+ * These are credits, and a binary64 that has been through a reconciler cannot
1467
+ * be reconciled back.
1468
+ */
1469
+ export interface CreditBalance {
1470
+ /** Unlapsed grants, refunds and adjustments, summed. */
1471
+ granted: string;
1472
+ /**
1473
+ * Entries whose expiry has passed, summed SEPARATELY rather than dropped.
1474
+ *
1475
+ * This field is the answer to "where did my 4,000 credits go". Without it a
1476
+ * reader can only show a smaller number, and the customer is told that the
1477
+ * database says so.
1478
+ */
1479
+ expired: string;
1480
+ /** Priced usage, all time, off the append-only meter. */
1481
+ spent: string;
1482
+ /** `granted - spent`. Negative means the pool was overspent. */
1483
+ balance: string;
1484
+ /**
1485
+ * `balance <= 0`: nothing left in the granted pool.
1486
+ *
1487
+ * REPORTED, NOT ENFORCED. Nothing in the control plane refuses work because
1488
+ * this is true: admission is decided against the plan's monthly allowance,
1489
+ * which a grant deliberately does not move. So an exhausted pool costs an
1490
+ * account nothing today, and a fresh grant buys it nothing either. That is a
1491
+ * product gap, it is named here so no reader mistakes this field for a gate,
1492
+ * and closing it is a pricing decision rather than a wiring one.
1493
+ */
1494
+ exhausted: boolean;
1495
+ /**
1496
+ * Usage rows carrying a price nothing can sum.
1497
+ *
1498
+ * Non-zero means somebody wrote a `credits` value to the meter out of band,
1499
+ * so `spent` is a lower bound and `balance` an upper one — every other figure
1500
+ * here is approximate while this is not zero, and saying so is the point.
1501
+ */
1502
+ malformed: number;
1503
+ }
1504
+ export declare const PROFILE_KEYS: readonly ["id", "label", "exit", "createdAt"];
1505
+ export declare const SESSION_QUERY_KEYS: readonly ["status", "outcome", "task", "since", "until", "profile", "limit", "cursor"];
1506
+ /**
1507
+ * The wire form of every list capability: `profiles.list`, `sessions.list`,
1508
+ * `monitors.list`.
1509
+ *
1510
+ * A bare array cannot be the wire form, because every list query carries a
1511
+ * `cursor` and a bare array has nowhere to return the next one. Making the
1512
+ * envelope a type rather than a convention is what stops four surfaces
1513
+ * agreeing today and drifting apart at the first one that forgets.
1514
+ *
1515
+ * `cursor` absent means the last page. It is deliberately not `null`: absence
1516
+ * is the end of the list, and a `null` that means the same thing gives callers
1517
+ * two ways to spell one state.
1518
+ *
1519
+ * `usage` is mandatory here as it is everywhere else. Usage rides on the result
1520
+ * that caused it rather than living in a metrics endpoint, so that the number
1521
+ * on the bill is always attached to the call that produced it. A list is a
1522
+ * billable call like any other.
1523
+ */
1524
+ export interface ListResponse<T> {
1525
+ data: T[];
1526
+ cursor?: string;
1527
+ usage: Usage;
1528
+ }
1529
+ /**
1530
+ * The body of a non-upgrade `GET /connect`: what the router hands a client so
1531
+ * it can reach a browser.
1532
+ *
1533
+ * This lives in core because it has two ends — the router writes it, the SDK
1534
+ * reads it — and neither owns it. Defining it in the server would make a
1535
+ * published client package depend on the control plane to learn a wire shape,
1536
+ * and copying it into both is the drift that having one contract package is
1537
+ * meant to prevent.
1538
+ */
1539
+ export interface SessionAllocation {
1540
+ /** Minted at allocation. The `sessions` row the worker will create. */
1541
+ sessionId: string;
1542
+ /** Hand this straight to `connectOverCDP`. */
1543
+ wsEndpoint: string;
1544
+ /** When the ticket in `wsEndpoint` stops being redeemable. */
1545
+ expiresAt: Timestamp;
1546
+ }
1547
+ /**
1548
+ * The time seam. Every wait and every deadline goes through this, so tests
1549
+ * drive a virtual clock instead of sleeping.
1550
+ *
1551
+ * `now` is a monotonic millisecond reading for measuring elapsed time. It is
1552
+ * **not** a wall-clock timestamp and must never be persisted or compared across
1553
+ * processes — use `Timestamp` for anything that outlives the process.
1554
+ *
1555
+ * This lives in core rather than in the first package that needed it because
1556
+ * three now need one independently — the render pipeline, the agent loop's
1557
+ * duration cap, and the supervisor's heartbeat — and none of them should take a
1558
+ * dependency on another's package to obtain a two-method interface.
1559
+ */
1560
+ export interface Clock {
1561
+ now(): number;
1562
+ sleep(ms: number): Promise<void>;
1563
+ }
1564
+ /**
1565
+ * The real one. `performance.now()` rather than `Date.now()`, because a
1566
+ * deadline must not move when NTP steps the wall clock.
1567
+ */
1568
+ export declare const systemClock: Clock;
1569
+ /**
1570
+ * Wall time, injected. Epoch milliseconds, for values that are **stored** or
1571
+ * compared across processes: when a cache entry was resolved, when a lease was
1572
+ * taken, when a profile was last seen.
1573
+ *
1574
+ * Deliberately a separate name from {@link Clock}, and deliberately not spelled
1575
+ * `clock`. The two have the same shape and opposite contracts, so anything that
1576
+ * unifies them by pattern-matching the word is a bug that does not announce
1577
+ * itself: `new Date(performance.now())` is a valid Date somewhere in 1970, and
1578
+ * every persisted timestamp is quietly wrong while every test still passes.
1579
+ *
1580
+ * Choosing between them: does the value outlive the process? Wall. Is it the
1581
+ * distance between two moments? Monotonic — and then it must be {@link Clock},
1582
+ * because wall time steps backwards when NTP corrects it.
1583
+ */
1584
+ export interface WallClock {
1585
+ /** Epoch milliseconds. Subject to NTP steps; never use it for a duration. */
1586
+ now(): number;
1587
+ }
1588
+ export declare const systemWallClock: WallClock;
1589
+ export type ValidationResult<T> = {
1590
+ ok: true;
1591
+ value: T;
1592
+ } | {
1593
+ ok: false;
1594
+ errors: string[];
1595
+ };
1596
+ /**
1597
+ * `"5m"` -> 300000. Throws on anything {@link DURATION_PATTERN} rejects.
1598
+ *
1599
+ * Canonical here rather than in `@outcrawl/runtime`, which is where it used to
1600
+ * live, because the grammar has to be enforced at BOTH trust boundaries — the
1601
+ * edge validator and the loop's own `validateCaps` — and a second parser beside
1602
+ * a second copy of the pattern is how the two come to disagree about what
1603
+ * `"90m"` means. `@outcrawl/runtime` re-exports this one.
1604
+ */
1605
+ export declare function parseDuration(spec: string): number;
1606
+ /**
1607
+ * Checks one `caps.duration` against the grammar, returning the error to report
1608
+ * or `undefined`.
1609
+ *
1610
+ * ── THERE IS NO CEILING HERE ANY MORE, AND THAT IS THE POINT ─────────────────
1611
+ *
1612
+ * This function used to refuse anything over `MAX_RUN_DURATION_MS` — 900,000,
1613
+ * spelled `15m` to customers — and the refusal was honest about its own
1614
+ * reasoning: the edge forwarded one `fetch` per REST call under an
1615
+ * `AbortSignal.timeout`, that signal bounded the whole call, and a run that
1616
+ * outlived it had its socket cut mid-stream with no terminal frame. So the
1617
+ * platform could not deliver a longer run and said so.
1618
+ *
1619
+ * A job has no held request. Nothing forwards, nothing times out, and the
1620
+ * number that used to be quoted as a product limit was never one — it was the
1621
+ * shape of the transport, wearing policy's clothes. `docs/AGENTS.md`: "the
1622
+ * refusal currently quoting it is a patch over that and is deleted when this
1623
+ * lands, not tuned." Deleted, not tuned. `caps` is the customer's dial, hours
1624
+ * are normal, and `"12h"` now means twelve hours.
1625
+ *
1626
+ * The function survives the ceiling because its OTHER reason outlives it:
1627
+ * `validateAgentRequest` here and `validateCaps` in `@outcrawl/runtime` must
1628
+ * refuse a malformed duration with the same sentence, so a caller who hits the
1629
+ * loop directly gets the same message as one who came through HTTP. Returns
1630
+ * rather than pushes, for that.
1631
+ */
1632
+ export declare function capDurationError(value: unknown): string | undefined;
1633
+ /**
1634
+ * The one canonical object guard for this package. `@outcrawl/core` has no
1635
+ * dependencies — it is the contract every other package imports, so it carries
1636
+ * no schema validator — and this guard is defined here once rather than
1637
+ * recreated at each boundary. It proves an object and nothing more; fields stay
1638
+ * `unknown` and are checked individually below.
1639
+ */
1640
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
1641
+ /**
1642
+ * Every field a submit accepts. This is part of the REQUEST CONTRACT, so it
1643
+ * lives beside the type and the validator rather than in whichever package
1644
+ * happens to parse a body first.
1645
+ *
1646
+ * It moved here from `@outcrawl/api`'s `AGENT_FIELDS` for a measured reason.
1647
+ * A run is a row, and the edge writes that row before it forwards. While the
1648
+ * accept-list was private to the api package the EDGE could not consult it, so
1649
+ * a submit carrying an unknown field was recorded, forwarded, refused a hop
1650
+ * later, and settled `failed` — a permanent run in the customer's history for
1651
+ * work they never successfully submitted. Measured 2026-09-08: one bad field,
1652
+ * `http=400`, `agent_runs` +1.
1653
+ *
1654
+ * `satisfies readonly (keyof AgentRequest)[]` is half its job; the other half
1655
+ * is `_requestFieldsExhaustive` in `@outcrawl/api`, which still fails to
1656
+ * compile if `AgentRequest` grows a field this list does not name. Both halves
1657
+ * now read ONE array, so the edge and the machine cannot disagree about what a
1658
+ * submit may carry.
1659
+ */
1660
+ export declare const AGENT_REQUEST_FIELDS: readonly ["task", "caps", "profile", "schema", "agent", "webhook", "notify", "exit", "cache", "record", "secrets", "rules", "integrations"];
1661
+ /**
1662
+ * The names in `input` that no submit accepts, in the order given. Empty means
1663
+ * every field is known — it does NOT mean the request is valid, which is
1664
+ * `validateAgentRequest`'s question.
1665
+ */
1666
+ export declare function unexpectedAgentFields(input: Record<string, unknown>): readonly string[];
1667
+ /**
1668
+ * Validates an agent request at the trust boundary — HTTP body, MCP tool call,
1669
+ * CLI flags. `caps` is required: an agent run with no ceiling is how a bill
1670
+ * becomes a story, so the request is rejected before a browser is allocated
1671
+ * rather than capped later.
1672
+ *
1673
+ * MCP supplies a conservative default when the caller omits `caps`; it must do
1674
+ * so *before* calling this.
1675
+ */
1676
+ export declare function validateAgentRequest(input: unknown): ValidationResult<AgentRequest>;
1677
+ export {};