mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-05 06:05:28 +00:00
* OpenErrand 0.1: an errand on a website with no API, with the human steps kept human docs/openerrand.md mints OpenErrand: one JSON file per errand (register an account, download a transcript) naming the site, the inputs with a sensitivity class and ordered sources (document, vault, prompt, generate, derive, candidate, literal), field rules matched by id then label, page and wait steps, five human gates a runner never performs (declare, identity-proofing, code, mail, captcha), outcomes, the never-retried shared secret, vault and download outputs, hand-off cards that may name only public inputs, the publisher index at /.well-known/openerrand.json, and thirteen runner rules. The worked example is the MyFTB business registration that cli-tools `ftb` performs (profullstack/cli-tools#125), with no personal data. - @logicsrc/schemas: openerrand + openerrand-index schemas and fixtures - @logicsrc/validators: semantic checks (references, templates, no personal or secret input on a card) and tests that validate the spec's own examples - logicsrc-web: registry entry (process family), /openerrand landing page, the example and the index served as static files, contract tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * OpenErrand: hand-off cards stay on the surface that owns the data Anthony's ruling: tax and finance data never touches a social or promotion tool, and nothing is sent to a CPA or preparer. - Hand-off cards are delivered only on the surface that owns the errand's data (for a tax or finance errand, the principal's finance app through its CLI, PWA, MCP server or API, such as CoinPay, or the runner's terminal), never a social, promotion or third-party posting service, and never to anyone but the principal. A card for an errand with personal or secret inputs does not leave that surface. Runner rule 9 says the same. - The run record and the sample run name the card by an opaque id (pin-letter/7f3k2q) instead of a mynaposter.com URL; the myna mention is gone. - `principal: represented` no longer cites a preparer with a power of attorney. - The FTB card's last step no longer suggests sending the PIN to someone else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * OpenErrand: user-agent rule, captcha solver policy, reference runner note Anthony's answers on #227 ("go with your recommendations"): - Rule 11: a runner may run headless with a normal desktop browser user agent (dropping HeadlessChrome) and nothing more: no fingerprint spoofing beyond the UA string, no stealth plugins, no solving or evading a bot challenge. A challenge the browser completes itself is a wait step; any other is a captcha gate. - Captcha solvers: new site.sector and captcha step `solver` (forbidden by default | allowed). Never allowed on government, tax, financial, healthcare or identity-provider sites, nor on any errand with a declare or identity-proofing step or a secret input; elsewhere only when the file says so, with every use logged. The validator rejects `allowed` in the forbidden set or without a stated sector; six new tests. The FTB example states sector "tax". - Reference runner: @logicsrc/openerrand / `logicsrc errand run`, marked in progress; ftb stays the runner the example was taken from. - Name stays OpenErrand; family stays Agents and process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * OpenErrand reference runner: @logicsrc/openerrand and logicsrc errand Ship the runner docs/openerrand.md promised. `logicsrc errand run <file>` reads an OpenErrand 0.1 file, validates it with @logicsrc/validators, and drives headless Chrome through it under the spec's thirteen rules; `errand validate` shows what a file will ask of you and `errand status` shows the last run of each errand, its card and any lockout. The engine is generalised from cli-tools `ftb` (PR #125) with no dependency on cli-tools: the CDP client and Chrome finder from wcag.ts, the page reader, native-setter fill and forward-button picker from ftb-run.ts, and the rule matcher, throttle and outcome logic from ftb.ts, all now driven by the file. - Inputs: document (an extractor hook; the one shipped runs a local command that reads JSON requests and prints records), vault (teams or OpenCreds), prompt (no echo for secrets), generate, derive, candidate, literal. `--input name=value` wins. Shared-secret candidates are ranked as the spec says, one is submitted, and a rejection lists the others for --candidate. - Rules: id before label, step rules first, choices before text, an id match final, an unmatched required field stops the run naming it. - Gates: declare only with --declare after the values are shown; identity proofing never touched (URL only) and handed over or stopped on; code from the terminal or a code file, used once, a wrong code waits for the next; mail ends the run waiting with the card; captcha is the person's, and a CaptchaSolver interface is called only where the spec permits (no solver is bundled); wait steps are polled, never solved. - Throttle: 2 runs per errand and account in 30 minutes, 4 a day, 2 minutes between runs on a site, lockouts from metadata.lockout or a default, held per site and account, never lifted by --force. - Outputs: credentials written before success to a teams vault by pull, merge, push (metadata.vault or --vault), else a 0600 file said aloud; downloads type-checked and never overwritten with different bytes; cards only in the local run record. - The user agent is Chrome's own with HeadlessChrome replaced, given at launch: a CDP override did not reach a navigation the page's own script started, which is exactly the proof-of-work interstitial case. Tests: 85 in the package (rule engine, inputs, gates, throttle, outcomes, captcha gating, vaults, outputs, commands) including an integration test that runs the published FTB example unchanged in real headless Chrome against a local HTTPS fake site (Chrome maps webapp.ftb.ca.gov to it and every other host to NOTFOUND; all data fictional), and 2 in the CLI. CLI 0.6.0 -> 0.7.0; @logicsrc/schemas and @logicsrc/validators 0.3.0 -> 0.4.0 (the OpenErrand schemas, and the vocabularies now exported for runners); PRD 0009; the spec's Reference runner section and the landing page say it ships. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
695 lines
45 KiB
Markdown
695 lines
45 KiB
Markdown
# OpenErrand
|
|
|
|
OpenErrand is one JSON file that describes an errand a person runs on a website that has no API: registering for a tax account, downloading a transcript, renewing a licence. It names the site and the pages to start from, the inputs and where each may come from, the rules that fill each form field, the steps a runner must hand to a person and never automate, what success and rejection look like on the page, what is kept afterwards and where, and the card a person gets when the errand has to wait for the post. A person reads the file before running it and knows every value it will send and every statement it will ask them to make; an agent runs the same file in a headless browser and stops exactly where a person is needed. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface.
|
|
|
|
Status: **0.1**. A description of an errand a runner already performs, published so others can write errands, review them and run them.
|
|
|
|
Slug: `openerrand`
|
|
|
|
## The problem
|
|
|
|
Government and institutional sites are where people spend the hours nobody wants to spend: a tax board, a licensing office, a benefits portal. Most have no API and no OAuth. An account is a browser form checked against a figure from last year's return, then a code by text, then a PIN by letter. The forms change without notice, a bot check sits in front of them, and one wrong answer can lock the account.
|
|
|
|
People already automate these. They do it with a script on one laptop that nobody else can read, or with an agent told "register me at the FTB" and left to guess. The script hides what it sends. The agent guesses at fields it has never seen, ticks a penalty-of-perjury box because the form would not submit without it, and tries a second figure when the first is rejected. Neither can be reviewed before it runs, and neither can be shared.
|
|
|
|
What is missing is the errand written down: the fields it fills and with what, the values that are secret, the steps that belong to a person, and the point where it stops. Written down, an errand can be read by the person it acts for, reviewed by someone else, published, and run by any runner that keeps the same promises.
|
|
|
|
## Terms
|
|
|
|
- An **errand** is one task on one site that ends in an outcome a person cares about: an account registered, an account activated, a document downloaded. Its **errand file** is the JSON document this specification describes.
|
|
- A **site** is the website the errand runs on, named by its origins.
|
|
- The **principal** is the person or organisation the errand acts for, and whose data it uses.
|
|
- A **runner** is the program that reads an errand file and drives a browser through it.
|
|
- An **input** is one value the errand needs: a name, a ZIP code, a figure from a return, a password.
|
|
- A **field** is one form control on a page, as a runner reads it: its id, name, type, the label a person would read, whether it is required, and its options.
|
|
- A **rule** decides what to do with a field.
|
|
- A **step** is one kind of thing that happens during a run: a form page, a wait, or a **gate**.
|
|
- A **gate** is a step only a person can take. A runner hands it to a person and never performs it.
|
|
- An **outcome** is what the site says at the end: success, rejected, or waiting.
|
|
- A **hand-off card** is the note a person gets when the errand needs them later. It carries steps and never a secret.
|
|
- A **publisher** is whoever writes and serves an errand file. It is usually not the site.
|
|
|
|
## The errand file
|
|
|
|
A JSON document, served as `application/json`, conventionally named `<name>.json`. The smallest valid errand is a site, one page step and one outcome:
|
|
|
|
```json
|
|
{
|
|
"type": "logicsrc.openerrand",
|
|
"version": "0.1",
|
|
"name": "example-contact-form",
|
|
"title": "Send the contact form",
|
|
"site": { "name": "Example", "origins": ["https://example.com"], "start": ["https://example.com/contact"] },
|
|
"steps": [{ "id": "form", "kind": "page" }],
|
|
"outcomes": [{ "name": "sent", "kind": "success", "text": "thank you" }]
|
|
}
|
|
```
|
|
|
|
The top-level keys:
|
|
|
|
| key | meaning |
|
|
| --- | --- |
|
|
| `type` | Always `logicsrc.openerrand`. |
|
|
| `version` | `0.1`. |
|
|
| `id` | The https URL the file is published at. The dedupe key for an index or a directory. |
|
|
| `name` | A slug, unique within the publisher: `ftb-register-business`. A mail gate's `resume` names another errand by it. |
|
|
| `title` | What a person reads first: `Register a MyFTB business account`. |
|
|
| `description` | A paragraph a person reads before running it. |
|
|
| `publisher` | The publisher's [OpenProfile.md](/openprofile) URL. |
|
|
| `updated` | When anything in the file last changed, ISO 8601. |
|
|
| `reference` | A URL for the runner or the code the errand was taken from. |
|
|
| `principal` | `self`: the person running it is the principal. `represented`: they act for the principal with authority to, as a corporation's officer does for the corporation. |
|
|
| `site` | `name`, `sector` (`government`, `tax`, `financial`, `healthcare`, `identity-provider`, `commercial` or `other`: who runs the site, which decides whether a captcha solver may ever be allowed), `origins` (the https origins the runner may navigate), `start` (the URLs a run opens, in order of preference), `terms` (the site's terms of use). |
|
|
| `limits` | `pages` (the most pages one run may submit, default 15), `page_timeout` (default `PT30S`), `same_page` (how many times the same URL may come back before the run stops as a loop, default 2). |
|
|
| `inputs` | The values the errand needs, by name. |
|
|
| `rules` | The field rules, applied on every page step. |
|
|
| `steps` | Page steps, waits and gates. |
|
|
| `submit` | How the runner finds a page's forward button. |
|
|
| `outcomes` | What success, rejection and waiting look like. |
|
|
| `retry` | What the runner may do again. |
|
|
| `outputs` | What is kept after a success, and where. |
|
|
| `handoffs` | The cards a person gets, by id. |
|
|
| `metadata` | Anything the publisher wants to add. Every other unknown key is an error, so a typo in a gate is caught before a run. |
|
|
|
|
Patterns throughout are ECMAScript regular expressions, matched case-insensitively. Durations are ISO 8601 (`PT15M`, `P21D`).
|
|
|
|
## Inputs
|
|
|
|
Each input has a `type` (`string`, `integer`, `number`, `email`, `date`, `boolean`, or `qa-set` for security questions and their answers), a `sensitivity`, and an ordered list of `sources`. Optional keys: `label` (what a person is shown), `required`, `pattern` (a value must match it), `max_length` (the value is cut to it when filled, because the site's box takes no more), `count` (for a `qa-set`), and `role`.
|
|
|
|
### Sensitivity
|
|
|
|
| class | what it is | where it may appear |
|
|
| --- | --- | --- |
|
|
| `public` | Anything already public or harmless: a corporation number on the state register, a tax year. | Logs, the terminal, hand-off cards, the run record. |
|
|
| `personal` | Identifies a person: a name, an address, an email, a phone number, a user name. | The terminal of the principal, the site's own fields, the vault. Never in a log, never on a card. |
|
|
| `secret` | Proves who someone is or opens an account: a password, a security answer, an SSN, a figure from a return used as a shared secret, a PIN. | The site's own field and the vault, and nowhere else. Masked on the terminal as `••••`. |
|
|
|
|
A runner treats an input with no stated sensitivity as an error, not as public.
|
|
|
|
### Sources
|
|
|
|
Sources are tried in order and the first that yields a value wins.
|
|
|
|
| source | where the value comes from |
|
|
| --- | --- |
|
|
| `document` | Extracted on the principal's machine from the principal's own files: `form` and `field` as printed (`CA 100S`, `line 20`), an optional `match` for the printed label, `pick` (`newest`, `oldest`, `each`), `years` (`back`, how many closed years count, and `current`, whether the year in progress does), and a `transform`. The runner records which file and page each value came from. The document never leaves the machine. |
|
|
| `vault` | A key in the principal's vault, such as an [OpenCreds](/opencreds) vault. Listed before `generate`, so a second run reuses the login the first one made. |
|
|
| `prompt` | Asked of a person at run time, with `ask` as the question. A secret prompt does not echo. |
|
|
| `generate` | A fresh random value from the runner's cryptographic generator: `length`, `classes` (`lower`, `upper`, `digit`, `special`) with at least one character of each, `special` for the characters the site accepts. A generated credential is written to the vault on success. |
|
|
| `derive` | Another input, with a `transform`: the numbers in a street address are `{ "from": "derive", "input": "street", "transform": "digits" }`. |
|
|
| `candidate` | One `part` (`year`, `form`, `field`, `source`) of the candidate chosen for a shared-secret input, so the tax year sent is the year the figure came from. |
|
|
| `literal` | A fixed `value`. |
|
|
|
|
Transforms are `digits` (keep the digits), `whole` (whole units, a leading minus for a loss, no separators), `upper`, `lower`, `trim`, `first:N` and `last:N`.
|
|
|
|
### Roles
|
|
|
|
- `shared-secret`: a value the site checks against its own records to prove the principal is who they say. Always `secret`. Its `document` sources produce a ranked list of candidates (sources in order, newest year first within each), and a run submits exactly one of them. See [Retry](#outcomes-and-retry).
|
|
- `credential`: a login the errand creates or uses. Written to the vault when `outputs.vault` names it.
|
|
- `identifier`: a number that names the principal at the site, such as an account number.
|
|
|
|
## Field rules
|
|
|
|
A runner reads every visible, enabled control on a page into a field: `id`, `name`, `type`, `label` (the `label for`, then `aria-label`, then a wrapping label, then the group's legend, then the placeholder), `required`, and `options` for a select. A rule is:
|
|
|
|
```json
|
|
{ "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } }
|
|
```
|
|
|
|
`name` appears in logs and errors. `id` is tested against the field's id; `label` against the field's id, name and label joined by spaces. `types` limits the rule to those field types; a rule without `types` fits any. At least one of `id` and `label` is required.
|
|
|
|
### Matching
|
|
|
|
1. **Id first, label second.** The runner tries every rule with an `id` before any rule's `label`. Ids are what the publisher saw on the site and are exact; labels cover pages not yet seen.
|
|
2. **The first fitting rule decides.** An id match is final even when its action is `skip`. A label match whose action cannot act (no option matches, the radio's label is the wrong one, the input has no value) lets the next rule try.
|
|
3. **A page step's own `rules` come before the errand's.**
|
|
4. **Choices before text.** Selects, radios and checkboxes are filled first; then the page is read again and text boxes are filled, because choosing a security question or a form type changes what the page asks next.
|
|
5. **An unmatched required field stops the run.** The runner names the field, its label and type, and the page. When the page step says `"unmatched": "ask"` and a person is at a terminal, the runner may ask them instead. It never guesses.
|
|
|
|
### Actions
|
|
|
|
| action | what the runner does |
|
|
| --- | --- |
|
|
| `text` | Sets the field to the template, with `{{input}}` replaced by the input's value. `split` (`[3, 2, 4]`) fills a value spread over several boxes, taking the part from the digit at the end of the field's id. |
|
|
| `select` | Chooses the first option whose text or value matches one of the patterns, tried in order. `{{input}}` inside a pattern is replaced by the value with regular-expression characters escaped. |
|
|
| `check` | `true` ticks the box or radio. `{ "label": pattern }` ticks a radio only when its own label matches. |
|
|
| `skip` | Leaves the field as it is. |
|
|
| `choose` | For a `qa-set` input: chooses the first question not yet used and records it with its answer. |
|
|
| `answer` | For a `qa-set` input: types the answer to the question chosen in the select before the box, or the question the page prints. |
|
|
| `gate` | Hands the field to the gate step with that id. |
|
|
|
|
The runner sets a value with the element's native setter and then fires `input`, `change` and `blur`, so a page's own validation sees it.
|
|
|
|
## Steps
|
|
|
|
On each page the runner takes the first step whose `match` fits (`url`, `title`, `text` as patterns, `selector` as a CSS selector that must find an element; all that are given must fit). A page step without `match` fits any page and is the fallback. Gates are also reached from a rule's `gate` action and from an outcome's `then`.
|
|
|
|
| kind | what it is |
|
|
| --- | --- |
|
|
| `page` | A form page: fill it from the rules, then press its forward button. |
|
|
| `wait` | An interstitial the page clears by itself, such as a proof-of-work bot check. The runner polls every `poll` until `match` no longer fits, and stops when `timeout` passes. It never solves or bypasses the check. |
|
|
| `declare` | A legal attestation. A gate. |
|
|
| `identity-proofing` | Proving who you are to an identity provider: a selfie, a video call, a document scan. A gate. |
|
|
| `code` | A one-time code sent to the principal. A gate. |
|
|
| `mail` | A letter the site posts to the principal. A gate. |
|
|
| `captcha` | A test meant to tell a person from a program. A gate, unless the errand allows a solver where [the captcha rules](#captcha) permit one. |
|
|
|
|
## Human gates
|
|
|
|
A gate is first-class because it is the part of an errand that matters most and the part an automation is most tempted to fake. Every gate step carries `why`, the sentence a person reads about what is being asked of them.
|
|
|
|
### `declare`
|
|
|
|
A box or button by which the principal states something under penalty of perjury, or otherwise attests that what was entered is true. `statement` is a pattern for the text.
|
|
|
|
The runner ticks it only with the principal's consent for this run, given after the runner has shown the values that will be attested, secrets masked. Consent is a flag the person types (`--declare`), a button they press, or an answer at a prompt. It is never read from a configuration file, an environment variable, the errand file, a saved default, or another agent's say-so. Without it the runner stops on that page, prints the statement word for word, and submits nothing.
|
|
|
|
### `identity-proofing`
|
|
|
|
A redirect to an identity provider (ID.me, Login.gov, a bank's own check) that asks for a face, a video call or a scan of an ID. `provider` names it and `origins` lists its origins.
|
|
|
|
A runner does not automate any of it. It does not click, type or run script on a page from those origins, does not supply a camera, microphone or file to the provider (no virtual camera, no recorded video, no emulated device), does not read what the provider shows, and does not ask a model to act as the person. It hands the browser session to the principal in a visible window, or ends the run with a hand-off card when no person is present. It resumes only when the page is back on one of `site.origins`, before `timeout`.
|
|
|
|
### `code`
|
|
|
|
A one-time code sent by `sms`, `email`, `voice` or an authenticator `app`. `relay` lists how it reaches the runner: `terminal` (typed at a prompt), `file` (written to a file the runner names and watches, by whoever holds the phone or by an agent the principal set up to relay their messages), or `page` (a box in the runner's own interface). `pattern` checks the code and `timeout` is how long the runner keeps the browser session open waiting for it.
|
|
|
|
The runner uses the code once and never logs it, stores it or shows it again. When a page offers a choice between a text and a call, a rule chooses the channel the relay can carry.
|
|
|
|
### `mail`
|
|
|
|
A letter, usually with a PIN, that only the addressee can read. `what` names it, `arrives` says when, `expires` is how long it is good for from the run. `resume` names the errand that continues once it comes, and `input` the input that errand takes from the letter.
|
|
|
|
A mail gate ends the run with outcome kind `waiting`. The runner delivers the `handoff` card, on the surface described in [Hand-off cards](#hand-off-cards), with `{{expires_on}}` set to the run date plus `expires`, and the continuing errand marks the card done when it succeeds.
|
|
|
|
### `captcha`
|
|
|
|
By default a runner shows the page to the principal in a visible window, or stops. It does not send the captcha to a solving service, a model, or a person paid to solve them.
|
|
|
|
`solver` on the step is `forbidden`, the default, or `allowed`. A solver is never allowed on a government, tax, financial, healthcare or identity-provider site, as `site.sector` states, and never on an errand with a `declare` or `identity-proofing` step or any `secret` input, whatever its sector. Elsewhere an errand may say `"solver": "allowed"` explicitly, and a runner that then uses a solver logs every use: the time, the page URL and the service, never the image or the answer. A validator rejects `allowed` on an errand in the forbidden set, and on an errand that does not state its `site.sector`.
|
|
|
|
A `wait` step is not a captcha: a proof of work the page's own script solves asks nothing of a person, and waiting for it is all a runner does.
|
|
|
|
## Outcomes and retry
|
|
|
|
Before filling each page, the runner reads the page's error messages (or, when there are none, its text) and tests every outcome of kind `rejected` first, then the others in order. `text` and `url` are patterns; when both are given both must fit. `then` names the step that follows an outcome, as the PIN letter follows a registration.
|
|
|
|
| kind | meaning |
|
|
| --- | --- |
|
|
| `success` | The errand did what it says. |
|
|
| `rejected` | The site said no. The run ends. |
|
|
| `waiting` | The errand is done for now and continues when a mail gate's letter arrives. |
|
|
|
|
A runner adds one outcome of its own, `stopped`, with the reason: `unmatched` (a required field nothing fills), `loop` (the same page came back `limits.same_page` times), `pages` (`limits.pages` passed), `timeout`, `gate` (a gate was refused or nobody was there), `off-site` (a navigation left `site.origins`), or `no-forward` (no forward button, with the buttons seen).
|
|
|
|
`retry` says what may be done again:
|
|
|
|
- `shared_secret` is always `never`, and is `never` when absent. A run submits one candidate for a shared-secret input. When the site rejects it, the run ends, the runner lists the other candidates with where each came from, and a person chooses the next one for the next run. A runner never submits a second candidate on its own, never cycles through them, and never sends a list. Sites lock accounts after a few wrong answers, and the person, not the runner, decides whether a lock is worth risking.
|
|
- `page_errors` is `rejected` (the default) or `continue`. With `rejected`, a validation error shown on any page after the first ends the run as rejected instead of being answered by a second guess.
|
|
|
|
A runner reports the run as a record an agent can read, with no input values in it:
|
|
|
|
```json
|
|
{
|
|
"errand": "https://logicsrc.com/examples/openerrand/ftb-register-business.json",
|
|
"outcome": "registered",
|
|
"kind": "success",
|
|
"at": "2026-10-04T17:20:11Z",
|
|
"page": "https://webapp.ftb.ca.gov/MyFTBAccess/Registration/Confirmation",
|
|
"candidate": { "year": 2025, "form": "CA 100S", "field": "line 20" },
|
|
"handoff": "pin-letter/7f3k2q"
|
|
}
|
|
```
|
|
|
|
## Outputs
|
|
|
|
`outputs.vault` writes credentials to the principal's vault when the outcome named in `when` happens: `keys` maps each vault key to a template. The vault is written before the runner reports success. A runner with no vault keeps them in a state file only the principal can read (mode 0600) and says so.
|
|
|
|
`outputs.downloads` files what the site hands over: each entry has a `match` (`url`, `filename` as patterns, `type` as a media type) and a `to` path template, such as `~/Documents/irs/{{tax_year}}/account-transcript.pdf`. A runner checks that a file is what `type` says before filing it, and never overwrites a file with different bytes.
|
|
|
|
Inputs never go anywhere else. A runner does not send them to a model, a telemetry endpoint, or a log. Its page log records each page's fields (selector, type, label, required, options) and never their values, which is what a publisher needs to fix a rule.
|
|
|
|
## Hand-off cards
|
|
|
|
A card is what a person gets when the errand needs them later: a title, numbered steps, a URL to `open`, and a `command` to run. The run record names it by an opaque id (`pin-letter/7f3k2q`), never by a URL on another service.
|
|
|
|
A card is delivered only on the surface that owns the errand's data. For a tax or finance errand that is the principal's finance app, through its own CLI, PWA, MCP server or API (CoinPay, for example), or the runner's own terminal. It is never delivered through a social network, a promotion or marketing tool, a posting or scheduling service, or any other third party, and never sent to anyone but the principal. A card for an errand with any personal or secret input does not leave that surface at all: no copy, link, notification text or preview of it is handed to another service.
|
|
|
|
Its templates may name `{{expires_on}}`, `{{errand.title}}`, `{{site.name}}` and public inputs, and nothing else. A runner refuses to render a card that names a personal or secret input, and a validator rejects the file. No PIN, password, SSN, figure from a return or address ever goes on one.
|
|
|
|
## Discovery
|
|
|
|
An errand file can be published at any https URL. A publisher lists its errands at `/.well-known/openerrand.json` on its own origin:
|
|
|
|
```json
|
|
{
|
|
"type": "logicsrc.openerrand-index",
|
|
"version": "0.1",
|
|
"publisher": "https://logicsrc.com/.well-known/openprofile.md",
|
|
"updated": "2026-10-04T00:00:00Z",
|
|
"errands": [
|
|
{
|
|
"url": "https://logicsrc.com/examples/openerrand/ftb-register-business.json",
|
|
"name": "ftb-register-business",
|
|
"site": "https://webapp.ftb.ca.gov",
|
|
"title": "Register a MyFTB business account",
|
|
"gates": ["declare", "code", "mail"],
|
|
"updated": "2026-10-04T00:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`gates` lets a person see, before fetching anything else, what an errand will ask of them. A page can also point at an index with `<link rel="openerrand" href="...">` or a `Link: <...>; rel="openerrand"` header.
|
|
|
|
An errand is **verified** when its file came from the origin of the index that lists it: that publisher stands behind it. It is **site-endorsed** only when the site itself serves the index, because only then has the institution said this is how its forms work. A runner shows both before the first run.
|
|
|
|
## The rules
|
|
|
|
A runner conforms to OpenErrand 0.1 when it does all of these.
|
|
|
|
### 1. It shows the errand before it runs it
|
|
|
|
The title, the publisher, whether the file is verified or site-endorsed, every gate with its `why`, and every input with its sensitivity and source. The file a person approved is the file that runs: a runner records the file's SHA-256 and shows the change before running a file whose hash differs.
|
|
|
|
### 2. It stays on the site
|
|
|
|
It navigates only to `site.origins` and to the origins of an identity-proofing gate it has handed to a person. A redirect anywhere else ends the run as `stopped: off-site`.
|
|
|
|
### 3. It matches by id, then by label, and never guesses
|
|
|
|
As in [Matching](#matching). A required field no rule fills stops the run, or is asked of a person at a terminal when the step allows it.
|
|
|
|
### 4. It never performs a gate
|
|
|
|
A `declare` box is ticked only on the principal's consent for this run. Identity proofing and letters are the person's, and so is a captcha unless the errand allows a solver where [the captcha rules](#captcha) permit one. A code comes only through a declared relay.
|
|
|
|
### 5. It submits one shared secret per run
|
|
|
|
And never retries one. A rejection ends the run and lists the other candidates for a person to choose from.
|
|
|
|
### 6. It keeps documents on the machine
|
|
|
|
Extraction runs locally. A document, or any page of one, is never uploaded, sent to a model, or copied off the machine by the runner.
|
|
|
|
### 7. It keeps each value in its class
|
|
|
|
`public` may go anywhere; `personal` goes to the principal's terminal, the site's fields and the vault; `secret` goes to the site's field and the vault, masked everywhere else.
|
|
|
|
### 8. It logs fields and never values
|
|
|
|
The page log holds selectors, types, labels, required flags and options. A value never appears in a log, an error, or the run record.
|
|
|
|
### 9. It puts nothing personal or secret on a card, and keeps the card where the data lives
|
|
|
|
Only built-ins and public inputs go on a card, and the card is delivered only on the surface that owns the errand's data (for a tax or finance errand, the principal's finance app or the runner's own terminal), never through a social, promotion or third-party posting service, as in [Hand-off cards](#hand-off-cards).
|
|
|
|
### 10. It writes credentials before it reports success
|
|
|
|
To the vault in `outputs.vault`, or to a 0600 state file when there is none. A login that exists only in a terminal's scrollback is a login lost.
|
|
|
|
### 11. It may look like a browser, and never defeats a check
|
|
|
|
A runner may run headless and present a normal desktop browser user agent, for example by dropping `HeadlessChrome` from it. That is as far as it goes: no fingerprint spoofing beyond the user-agent string, no stealth plugins, and no solving, skipping or evading a bot challenge. A challenge the browser completes on its own, such as a proof-of-work interstitial, is a `wait` step, polled until it clears or times out. Any other challenge is a `captcha` gate.
|
|
|
|
### 12. It has a dry run
|
|
|
|
A dry run fills every page up to the first page that holds a `declare` gate or a shared-secret input, prints what it would send there with secrets masked and the declaration word for word, and stops before that page's forward button. Nothing a site keeps is created by a dry run.
|
|
|
|
### 13. It stops on a loop
|
|
|
|
The same URL `limits.same_page` times, or more than `limits.pages` pages, ends the run as stopped, with the page log path.
|
|
|
|
## Worked example
|
|
|
|
Registering a MyFTB account for a California S corporation at the Franchise Tax Board. FTB has no API: the account is a browser registration checked against a figure from a filed Form 100S, a code texted to the representative's phone, and a PIN mailed to the address on file. `ftb` in [cli-tools](https://github.com/profullstack/cli-tools/pull/125) performs it today, and this file transcribes its rule table, gates and outcomes. The file holds no personal data: every value comes from the principal's own returns, vault or terminal at run time.
|
|
|
|
```json
|
|
{
|
|
"type": "logicsrc.openerrand",
|
|
"version": "0.1",
|
|
"id": "https://logicsrc.com/examples/openerrand/ftb-register-business.json",
|
|
"name": "ftb-register-business",
|
|
"title": "Register a MyFTB business account",
|
|
"description": "Creates a MyFTB account for a California S corporation at the Franchise Tax Board, proving the business with a figure from a filed Form 100S. FTB then mails a PIN; activation is a second errand.",
|
|
"publisher": "https://logicsrc.com/.well-known/openprofile.md",
|
|
"updated": "2026-10-04T00:00:00Z",
|
|
"reference": "https://github.com/profullstack/cli-tools/pull/125",
|
|
"principal": "self",
|
|
"site": {
|
|
"name": "California Franchise Tax Board (MyFTB)",
|
|
"sector": "tax",
|
|
"origins": ["https://webapp.ftb.ca.gov"],
|
|
"start": ["https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount"]
|
|
},
|
|
"limits": { "pages": 15, "page_timeout": "PT30S", "same_page": 2 },
|
|
"inputs": {
|
|
"email": {
|
|
"label": "Email address FTB writes to",
|
|
"type": "email",
|
|
"sensitivity": "personal",
|
|
"required": true,
|
|
"sources": [{ "from": "prompt" }]
|
|
},
|
|
"phone": {
|
|
"label": "Mobile number FTB texts a verification code to",
|
|
"type": "string",
|
|
"pattern": "^[0-9]{10}$",
|
|
"sensitivity": "personal",
|
|
"required": true,
|
|
"sources": [{ "from": "prompt" }]
|
|
},
|
|
"first_name": {
|
|
"label": "Representative's first name",
|
|
"type": "string",
|
|
"max_length": 11,
|
|
"sensitivity": "personal",
|
|
"sources": [{ "from": "document", "form": "CA 540", "field": "first name", "pick": "newest" }]
|
|
},
|
|
"last_name": {
|
|
"label": "Representative's last name",
|
|
"type": "string",
|
|
"max_length": 13,
|
|
"sensitivity": "personal",
|
|
"sources": [{ "from": "document", "form": "CA 540", "field": "last name", "pick": "newest" }]
|
|
},
|
|
"street": {
|
|
"label": "Street address on the newest return",
|
|
"type": "string",
|
|
"sensitivity": "personal",
|
|
"sources": [{ "from": "document", "form": "CA 540", "field": "street address", "pick": "newest" }]
|
|
},
|
|
"address_numbers": {
|
|
"label": "The numbers in the address on file",
|
|
"type": "string",
|
|
"sensitivity": "personal",
|
|
"sources": [{ "from": "derive", "input": "street", "transform": "digits" }]
|
|
},
|
|
"zip": {
|
|
"label": "ZIP code on file",
|
|
"type": "string",
|
|
"sensitivity": "personal",
|
|
"sources": [{ "from": "document", "form": "CA 540", "field": "ZIP code", "pick": "newest", "transform": "first:5" }]
|
|
},
|
|
"corp_id": {
|
|
"label": "California corporation number",
|
|
"type": "string",
|
|
"sensitivity": "public",
|
|
"sources": [{ "from": "document", "form": "CA 100S", "field": "California corporation number", "pick": "newest" }]
|
|
},
|
|
"tax_year": {
|
|
"label": "Tax year of the return the shared secret comes from",
|
|
"type": "integer",
|
|
"sensitivity": "public",
|
|
"sources": [{ "from": "candidate", "input": "net_income", "part": "year" }]
|
|
},
|
|
"net_income": {
|
|
"label": "Net income for tax purposes, whole dollars",
|
|
"type": "integer",
|
|
"sensitivity": "secret",
|
|
"role": "shared-secret",
|
|
"sources": [
|
|
{ "from": "document", "form": "CA 100S", "field": "line 20", "match": "net income for tax purposes", "years": { "back": 5, "current": false }, "transform": "whole" },
|
|
{ "from": "document", "form": "CA 100S", "field": "line 15", "match": "net income \\(loss\\) for state purposes", "years": { "back": 5, "current": false }, "transform": "whole" }
|
|
]
|
|
},
|
|
"username": {
|
|
"label": "MyFTB user name",
|
|
"type": "string",
|
|
"sensitivity": "personal",
|
|
"role": "credential",
|
|
"sources": [
|
|
{ "from": "vault", "key": "FTB_BUSINESS_USERNAME" },
|
|
{ "from": "generate", "length": 15, "classes": ["lower", "digit"] }
|
|
]
|
|
},
|
|
"password": {
|
|
"label": "MyFTB password",
|
|
"type": "string",
|
|
"sensitivity": "secret",
|
|
"role": "credential",
|
|
"sources": [
|
|
{ "from": "vault", "key": "FTB_BUSINESS_PASSWORD" },
|
|
{ "from": "generate", "length": 24, "classes": ["lower", "upper", "digit", "special"], "special": "!#$*@" }
|
|
]
|
|
},
|
|
"security": {
|
|
"label": "Three security questions and their answers",
|
|
"type": "qa-set",
|
|
"count": 3,
|
|
"sensitivity": "secret",
|
|
"role": "credential",
|
|
"sources": [
|
|
{ "from": "vault", "key": "FTB_BUSINESS_SECURITY_ANSWERS" },
|
|
{ "from": "generate", "length": 10, "classes": ["lower", "digit"] }
|
|
]
|
|
}
|
|
},
|
|
"rules": [
|
|
{ "name": "read terms", "id": "^ReadTerms$", "types": ["checkbox"], "do": { "check": true } },
|
|
{ "name": "accept terms", "id": "^AcceptTerms$", "types": ["checkbox"], "do": { "check": true } },
|
|
{ "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } },
|
|
{ "name": "middle initial", "id": "^MInitial$", "do": { "skip": true } },
|
|
{ "name": "last name", "id": "^LstName$", "do": { "text": "{{last_name}}" } },
|
|
{ "name": "suffix", "id": "^Sffx$", "do": { "skip": true } },
|
|
{ "name": "user name again", "id": "^ReUserName$", "do": { "text": "{{username}}" } },
|
|
{ "name": "user name", "id": "^UserName$", "do": { "text": "{{username}}" } },
|
|
{ "name": "email again", "id": "^ReEmail$", "do": { "text": "{{email}}" } },
|
|
{ "name": "email", "id": "^Email$", "do": { "text": "{{email}}" } },
|
|
{ "name": "password again", "id": "^RePassword$", "do": { "text": "{{password}}" } },
|
|
{ "name": "password", "id": "^Password$", "do": { "text": "{{password}}" } },
|
|
{ "name": "foreign number", "id": "^Phone_Foreign$", "do": { "skip": true } },
|
|
{ "name": "foreign address", "id": "^Address_Foreign$|^Address_No(MailAddress|PostalCode)$", "do": { "skip": true } },
|
|
{ "name": "security question", "label": "question", "types": ["select-one"], "do": { "choose": "security" } },
|
|
{ "name": "security answer", "label": "question|answer", "types": ["text", "password"], "do": { "answer": "security" } },
|
|
{ "name": "role", "label": "individual|business representative", "types": ["radio"], "do": { "check": { "label": "^\\s*business representative" } } },
|
|
{ "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } },
|
|
{ "name": "address numbers", "label": "numbers in (the |your )?(business )?(mailing )?address", "types": ["text", "tel", "number"], "do": { "text": "{{address_numbers}}" } },
|
|
{ "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["select-one"], "do": { "select": ["^\\s*{{tax_year}}\\s*$"] } },
|
|
{ "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["text", "tel", "number"], "do": { "text": "{{tax_year}}" } },
|
|
{ "name": "net income", "label": "net income|income \\(loss\\)", "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } },
|
|
{ "name": "company type", "label": "type of company|company type|entity type", "types": ["select-one"], "do": { "select": ["^\\s*corporation\\s*$", "corporation"] } },
|
|
{ "name": "account number", "label": "account number|entity id|corporation (id|number)", "types": ["text", "tel", "number"], "do": { "text": "{{corp_id}}" } },
|
|
{ "name": "form type", "label": "form type|type of (tax )?(return|form)", "types": ["select-one"], "do": { "select": ["100\\s*S\\b"] } },
|
|
{ "name": "declaration", "label": "perjury|i declare|under penalty", "types": ["checkbox"], "do": { "gate": "declaration" } },
|
|
{ "name": "phone", "label": "phone number", "types": ["text", "tel", "number"], "do": { "text": "{{phone}}" } },
|
|
{ "name": "send a text", "label": "send me a text|text message", "types": ["radio"], "do": { "check": true } },
|
|
{ "name": "verification code", "label": "verification code|security code|one[- ]time|passcode|access code|enter (the )?code", "types": ["text", "tel", "number", "password"], "do": { "gate": "text-code" } }
|
|
],
|
|
"steps": [
|
|
{
|
|
"id": "bot-check",
|
|
"kind": "wait",
|
|
"match": { "title": "^Challenge Validation$", "selector": "#sec-cpt-if" },
|
|
"timeout": "PT90S",
|
|
"poll": "PT3S",
|
|
"say": "FTB's bot check is a proof of work the page's own script solves; the runner waits for it."
|
|
},
|
|
{
|
|
"id": "form",
|
|
"kind": "page",
|
|
"unmatched": "stop",
|
|
"say": "Every MyFTB registration page: terms, profile, security questions, role, address, shared secret, phone."
|
|
},
|
|
{
|
|
"id": "declaration",
|
|
"kind": "declare",
|
|
"statement": "perjury|i declare|under penalty",
|
|
"why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true. Only that person can make the statement."
|
|
},
|
|
{
|
|
"id": "text-code",
|
|
"kind": "code",
|
|
"channel": "sms",
|
|
"relay": ["terminal", "file"],
|
|
"pattern": "^\\w{4,10}$",
|
|
"timeout": "PT15M",
|
|
"why": "FTB texts a code to the phone number given. Whoever holds the phone reads it out."
|
|
},
|
|
{
|
|
"id": "pin-letter",
|
|
"kind": "mail",
|
|
"what": "MyFTB PIN letter",
|
|
"arrives": "5 to 10 business days, to the address FTB has on file",
|
|
"expires": "P21D",
|
|
"resume": "ftb-activate-business",
|
|
"input": "pin",
|
|
"handoff": "pin-letter",
|
|
"why": "FTB activates a new account with a PIN it sends by US Mail. Nobody but the addressee can read it."
|
|
}
|
|
],
|
|
"submit": {
|
|
"labels": "^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$",
|
|
"never": "^(back|cancel|end session|previous)$",
|
|
"ignore": "#timer, .modal"
|
|
},
|
|
"outcomes": [
|
|
{
|
|
"name": "rejected",
|
|
"kind": "rejected",
|
|
"text": "does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked"
|
|
},
|
|
{
|
|
"name": "registered",
|
|
"kind": "success",
|
|
"text": "registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)",
|
|
"then": "pin-letter"
|
|
}
|
|
],
|
|
"retry": { "shared_secret": "never", "page_errors": "rejected" },
|
|
"outputs": {
|
|
"vault": {
|
|
"when": "registered",
|
|
"keys": {
|
|
"FTB_BUSINESS_USERNAME": "{{username}}",
|
|
"FTB_BUSINESS_PASSWORD": "{{password}}",
|
|
"FTB_BUSINESS_EMAIL": "{{email}}",
|
|
"FTB_BUSINESS_SECURITY_ANSWERS": "{{security}}"
|
|
}
|
|
}
|
|
},
|
|
"handoffs": {
|
|
"pin-letter": {
|
|
"title": "FTB PIN letter: business MyFTB account",
|
|
"open": "https://webapp.ftb.ca.gov/MyFTBAccess/",
|
|
"steps": [
|
|
"Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).",
|
|
"Activate before {{expires_on}}: the PIN expires 21 days after registration.",
|
|
"Run the command below yourself, with the PIN from the letter."
|
|
],
|
|
"command": "ftb activate business --pin <PIN from the letter>"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
What a run looks like, for a fictional representative Jane Doe of 1234 Maple St, Sacramento, CA 95814, and her corporation, number 1234567. The candidates for the shared secret come first:
|
|
|
|
```
|
|
$ ftb secrets
|
|
Business (Form 100S), best first:
|
|
2025 100S line 20 48210 corp 1234567 2025/100S.pdf p3
|
|
2025 100S line 15 51377 corp 1234567 2025/100S.pdf p3
|
|
2024 100S line 20 39875 corp 1234567 2024/100S.pdf p3
|
|
```
|
|
|
|
A dry run walks the pages and stops on the one with the declaration:
|
|
|
|
```
|
|
$ ftb register business --email jane@example.com --phone 5555550100 --dry-run
|
|
Registering a business MyFTB account as jdoeb4k2x <jane@example.com>
|
|
shared secret: 2025 Form 100S line 20 = 48210 from 2025/100S.pdf p3
|
|
address on file: 1234 / 95814 corp 1234567
|
|
Registration https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount
|
|
read terms
|
|
accept terms
|
|
-> Continue
|
|
...
|
|
company type = Corporation
|
|
form type = 100S
|
|
tax year = 2025
|
|
declaration
|
|
account number = 1234567
|
|
net income = ••••
|
|
Dry run: stopped before the first Continue that sends anything to FTB.
|
|
```
|
|
|
|
Jane checks the values, then runs it with `--declare`. FTB texts a code; she types it at the prompt (or it is written to the code file). FTB answers with its confirmation, which matches the `registered` outcome; the runner writes the login to the vault, follows `then` to the `pin-letter` mail gate, keeps the card on the runner's own surface, and ends the run as waiting:
|
|
|
|
```
|
|
Registered: business MyFTB account jdoeb4k2x. FTB mails a PIN to the address on file.
|
|
login saved to the ftb vault (profullstack/prod)
|
|
PIN-letter card: pin-letter/7f3k2q (ftb status shows it)
|
|
When the letter comes: ftb activate business --pin <PIN>
|
|
```
|
|
|
|
Had FTB answered "does not match our records", the run would have ended there with the 2024 figure listed as the next candidate, and nothing would have been retried.
|
|
|
|
## A second errand, sketched
|
|
|
|
The next errand planned is IRS.gov: sign in through ID.me and download account and return transcripts. It has not been run, and this fragment shows only what is new, the identity-proofing gate and the downloads:
|
|
|
|
```json
|
|
{
|
|
"steps": [
|
|
{
|
|
"id": "id-me",
|
|
"kind": "identity-proofing",
|
|
"provider": "ID.me",
|
|
"origins": ["https://api.id.me", "https://account.id.me"],
|
|
"timeout": "PT30M",
|
|
"handoff": "id-me",
|
|
"why": "The IRS signs people in through ID.me, which may ask for a selfie or a video call. That is yours to do; the runner waits until you are back on irs.gov."
|
|
}
|
|
],
|
|
"outputs": {
|
|
"downloads": [
|
|
{ "match": { "type": "application/pdf", "url": "transcript" }, "to": "~/Documents/irs/{{tax_year}}/transcript.pdf", "when": "downloaded" }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Schema
|
|
|
|
The JSON Schemas are `logicsrc-openerrand.schema.json` and `logicsrc-openerrand-index.schema.json` in [`@logicsrc/schemas`](https://github.com/profullstack/logicsrc/tree/master/packages/schemas/schemas), exported as `@logicsrc/schemas/openerrand` and `@logicsrc/schemas/openerrand-index`. `@logicsrc/validators` adds the checks a schema cannot express: step, gate, outcome and card references resolve, every `{{template}}` names an input, a shared secret is `secret`, no card names a personal or secret input, and a captcha solver is allowed only outside the forbidden set.
|
|
|
|
```
|
|
npx @logicsrc/validators openerrand ftb-register-business.json
|
|
```
|
|
|
|
## Reference runner
|
|
|
|
`logicsrc errand run <file>`, from the [`@logicsrc/openerrand`](https://github.com/profullstack/logicsrc/tree/master/packages/openerrand) package, reads an errand file, validates it with `@logicsrc/validators`, and drives headless Chrome through it under the rules above. `logicsrc errand validate <file>` shows what a file will ask of you; `logicsrc errand status` shows the last run of each errand, its hand-off card and any lockout.
|
|
|
|
```
|
|
logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --dry-run
|
|
logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --declare --vault teams:profullstack/ftb/prod
|
|
```
|
|
|
|
What the runner adds that the file does not say:
|
|
|
|
- **Documents** come from an extractor the principal names with `--extractor`: a local command that reads the requested forms and fields as JSON on stdin and prints records (`form`, `field`, `value`, `year`, `label`, `file`, `page`). The runner ships no extractor of its own.
|
|
- **The vault** is `--vault`, or a string in the file's `metadata.vault`: `teams:<team>/<project>/<env>` (read with `logicsrc teams pull`, written by pull, merge, push), `opencreds` (read-only), or `file:<path>`. With none, credentials go to a 0600 file under `~/.local/share/logicsrc/errand/credentials/` and the runner says so.
|
|
- **A code** is typed at the prompt, or written to `~/.local/share/logicsrc/errand/codes/<name>.code` by whoever holds the phone. A wrong code waits for the next one.
|
|
- **The throttle**: 2 runs of an errand per account in 30 minutes, 4 a day, 2 minutes between any two runs on one site, and nothing at all during a recorded lockout. A lockout is read from `metadata.lockout.text` (a pattern) and lasts `metadata.lockout.duration`, or a default pattern and 35 minutes. `--force` lifts the caps and never a lockout.
|
|
- **One Chrome profile per site** is kept between runs, so a bot check the browser has passed stays passed. The user agent drops `HeadlessChrome` and nothing more.
|
|
- **A captcha solver** is an interface a program embedding the runner may pass; none is bundled, and the runner calls one only where the [captcha rules](#captcha) permit it.
|
|
|
|
`ftb` in [cli-tools](https://github.com/profullstack/cli-tools/pull/125) is the runner the worked example was taken from; it has the same rule table compiled in rather than reading the file.
|
|
|
|
## Not
|
|
|
|
**Not a way around a check.** A runner may present an ordinary browser user agent and nothing more. There is no key for proxies or browser fingerprints, no gate a runner may perform, and a captcha solver only where [the captcha rules](#captcha) allow one. A site that wants a person gets one.
|
|
|
|
**Not for someone else's account.** The principal is the person running it or someone they represent with authority. An errand run against a stranger's records is the fraud the site's checks exist to stop.
|
|
|
|
**Not an API.** When a site has one, use it: [OpenConnection](/openconnection) for a token you paste, [OpenAccess](/openaccess) for a grant, [OpenSaaS](/opensaas) for the actions a subscription service publishes. An errand is for sites that offer a person a form and nothing else.
|
|
|
|
**Not a scraper.** An errand does one task for its principal and stops. It does not crawl, and it reads nothing the principal could not read in their own browser.
|
|
|
|
**Not a test framework.** Playwright and Selenium test a site its owner controls. An errand drives a site its publisher does not control, so it stops on anything it has not seen instead of failing a test.
|
|
|
|
**Not a credential store.** Logins go to a vault such as [OpenCreds](/opencreds); the errand file names keys, not values.
|
|
|
|
**Not legal or tax advice.** A declaration is the principal's statement, and an errand only carries it to the form.
|
|
|
|
## Relationship to other standards
|
|
|
|
- [OpenSaaS](/opensaas) describes each action as a `page` for a person and an `api` for an agent. An errand is what an agent does when there is only the `page`.
|
|
- [OpenCreds](/opencreds) is the vault `outputs.vault` writes to and `vault` sources read from.
|
|
- [OpenFleet](/openfleet) is the record an agent session carries; an agent running an errand runs it under that record, and the gates are where it hands back to its human.
|
|
- [OpenProfile.md](/openprofile) names the `publisher`.
|
|
- Selenium IDE's `.side` files and Playwright's recorded scripts replay clicks on selectors. An errand describes fields by meaning, with a fallback from id to label, so it survives a reworded page or stops on one, and it says which steps are a person's.
|
|
- The CNCF Open Workflow Specification (formerly Serverless Workflow) orchestrates services through their APIs. An errand drives a browser through pages; the two do not overlap, which is also why this is not called a workflow.
|
|
|
|
## Version history
|
|
|
|
| Version | Date | Change |
|
|
| --- | --- | --- |
|
|
| 0.1 | 2026-10-04 | First publication: the errand file, inputs with three sensitivity classes and seven sources, field rules matched by id then label, page and wait steps, five human gates (`declare`, `identity-proofing`, `code`, `mail`, `captcha`), outcomes, the never-retry rule for shared secrets, vault and download outputs, hand-off cards with no personal data, the publisher index at `/.well-known/openerrand.json`, thirteen runner rules (among them: a normal browser user agent and nothing more), the captcha solver policy keyed on `site.sector`, and the MyFTB business registration as the worked example. |
|
|
|
|
## License
|
|
|
|
The specification text is CC BY 4.0. Serve it, copy it, extend it.
|