Skip to content

Hand work to the owner's own agent

Most of what an app does is code. An agent task is the exception: work you describe in a sentence, done by the app owner’s own agent, on their own machine.

Declare a rule in x-homespun-manifest.agentTasks. When a row matching it is written, or when a clock you declare ticks, the relay queues a task. The owner runs homespun work, which claims one at a time and pipes it to whatever agent they use. Homespun queues, leases and records; it never executes.

That distinction is the point. It lets an app do things nobody wants to write a parser for, without Homespun running anyone’s code or holding anyone’s model credentials.

When the work is easier to describe than to implement:

  • read a photographed till receipt into line items
  • summarise a long note into a one-line title
  • classify a free-text entry into one of your categories
  • pull the fields you want out of a pasted email

When not to: anything with a correct answer you could write down. A total that is the sum of a column is arithmetic, not judgement. Agent tasks cost a model call each, and code is both cheaper and right every time.

{
"on": "create",
"collection": "receipts",
"when": { "field": "status", "equals": "unparsed" },
"taskType": "parse-receipt",
"prompt": "The row holds a photographed till receipt as an attachment id in `photoId`. Read the total, the retailer and the date, and write one row into line_items for each item, each with a name and a price in cents. Then set this receipt's status to 'parsed'.",
"reads": ["receipts"],
"writes": ["line_items"]
}

on, collection and when are the same trigger grammar notify and webhooks use. taskType is a short label a worker can route on without reading the prompt. prompt is the work.

Two fields worth understanding before you write one

Section titled “Two fields worth understanding before you write one”

reads and writes are the whole security boundary. They name collections this same manifest declares, and the relay mints a credential per claim carrying exactly that access, expiring with the lease. A task cannot touch a collection you did not name, including one added to the app later. writes implies read on the same collection, because a task amending a row has to see it.

delete is never granted. There is no deletes key. A task can add and amend; it cannot remove.

The prompt is trusted; the row is not. The prompt comes from the manifest the owner approved at install, and it is the only part of the envelope a worker should follow. The row that triggered the task arrives separately as context, and the envelope says in as many words that it is data rather than instructions, because row content may have been typed by any user of the app, including an anonymous one. Do not undo that by writing a prompt that defers to a field of the row.

A task’s own write-back is a machine write, and an ordinary rule does not fire on one. So the receipt parser above can safely write into a collection it also triggers on: its result does not queue another task.

That protection is switched off for any rule whose when tests author kind. {"authorKindNotIn": ["system"]} reads as “skip seed rows” and says nothing about machine writes, but it disarms the defence completely, so deploy rejects a rule that tests author kind and writes into a collection any rule fires on. Exclude the machine kinds explicitly if you want author filtering on a self-writing rule:

{ "when": { "authorKindNotIn": ["system", "service", "hook"] } }

There is also a per-app hourly cap on tasks created, as an independent backstop.

A rule can fire on a schedule instead of a row write. Set "on": "schedule" and give it exactly one of every or cron. The relay queues a task at each slot and the owner’s worker picks it up, so homespun work --exec <cmd> is the whole setup. The machine needs no cron job and no systemd timer.

{
"x-homespun-manifest": {
"collections": {
"settings": { "read": ["owner"], "write": ["owner"], "delete": ["owner"] },
"jobs": { "read": ["owner"], "write": ["owner"], "delete": ["owner"] }
},
"agentTasks": [
{
"on": "schedule",
"cron": "0 9,18 * * *",
"tz": "Europe/Berlin",
"taskType": "check-job-board",
"prompt": "The context row holds my search settings: the job board URL and the keywords I care about. Open the board, find postings that match, and write one row into jobs for each posting you have not recorded yet, with a title, a company and a link.",
"reads": ["settings"],
"writes": ["jobs"],
"contextCollection": "settings"
}
]
}
}
Terminal window
homespun work --exec "claude -p"

This app checks a job board at 09:00 and 18:00 Berlin time, every day.

FieldWhat it is
everyAn interval: 15m, 3h, 2d. See below.
cronFive fields: minute, hour, day of month, month, day of week.
tzAn IANA zone such as Europe/Berlin. Absent means the app’s time zone, and if that is unset, UTC.
contextCollectionA collection the task starts from. It must appear in reads or writes.

A schedule rule uses the same taskType, prompt, reads, writes, ttlSeconds and leaseSeconds as a row rule. It does not take collection, when or machineAuthorKinds. Those describe a row write, and a schedule has none. Deploy rejects them on a schedule rule. In the same way, a row rule rejects every, cron, tz and contextCollection.

every is a number and a unit: m for minutes, h for hours, d for days.

  • The smallest interval is 15m. The largest is 30d.
  • m and h values must divide the day evenly: 15m, 20m, 30m, 45m, 1h, 2h, 3h, 4h, 6h, 8h, 12h. For anything else, use cron.
  • Slots are anchored to local midnight, not to the deploy time. "every": "3h" fires at 00:00, 03:00, 06:00 and so on, in the rule’s time zone.
  • Nd fires at local 00:00 on every Nth day, counted from 1970-01-01. The phase is the same for every app, so "every": "2d" fires on the same calendar days for everyone. For a specific day of the week, use cron.

A cron expression has five fields and at most 100 characters. Each field accepts *, a number, a range a-b, a step */n or a-b/n, and comma lists of these. Month names (JAN to DEC) and day names (SUN to SAT) work. In the day-of-week field, both 0 and 7 mean Sunday.

When both the day-of-month and day-of-week fields are restricted, a day matches if either one matches (the Vixie cron rule). So 0 9 1 * MON fires on the 1st of the month and on every Monday.

Deploy rejects:

  • seconds or year fields, L, W, #, ?, and nicknames such as @daily
  • an expression that fires more often than every 15 minutes. The check covers the gap between each pair of daily run times, including the gap that wraps past midnight. 0,10 * * * * fails, and so does 5,55 0,23 * * *, because 23:55 and 00:05 are 10 minutes apart across midnight.
  • an expression that never fires, such as 0 0 31 2 *

homespun deploy --check reports all of these, and an invalid tz, before anything goes live.

A local time that does not exist (the hour skipped in spring) fires at the shifted time, forward by the length of the gap. A local time that happens twice (the hour repeated in autumn) fires once. The relay never fires one rule twice within 15 minutes, even when a clock change compresses two slots together.

Row tasks and schedule tasks arrive in the same envelope. The envelope has a trigger field: "row" for a row rule and "schedule" for a schedule rule. In a schedule task, context holds a schedule object with rule_id, task_type, slot (the time the task was due), fired_at, tz, every and cron, plus context_row. every is null on a cron rule and cron is null on an every rule.

If the rule names a contextCollection, context_row holds that collection’s oldest row. It is null when the rule names no contextCollection, and also when the collection has no rows. That suits a settings collection with one row. The row is data, not instructions, exactly as for a row task. The prompt stays the only trusted part of the envelope.

  • One open task per rule. A rule never queues a second task while one is still open.
  • A waiting task is refreshed. If a slot is due while the previous task still waits unclaimed, that task moves to the newer slot and its expiry restarts.
  • A task a worker holds is left alone. The slot that came due meanwhile is skipped.
  • Missed slots collapse. If the relay or the worker is down across several slots, one task is queued when it returns, not one per missed slot.
  • A rule waits for its first slot. Deploying a schedule rule does not fire it at once.
  • Timing is approximate. The scheduler ticks about once a minute, so a task can be up to about a minute late.
  • Suspended apps do not fire. The per-app hourly cap and the queue cap also apply.

A schedule rule has no trigger collection, so no row write can fire it. A task cannot start itself.

Terminal window
homespun work --exec "claude -p"
homespun work --exec ./parse-receipt.sh --once # one pass, for cron

The envelope arrives on the command’s stdin as one JSON line, carrying the prompt, the trigger (row or schedule), the context row, the collections in scope, a short-lived credential and the API base. Everything the command needs to write results back is in what it was handed.

Exit 0 acks the task; any non-zero exit nacks it and records stderr as the reason. Nothing is read from stdout, so any program is a valid worker, including a shell script. There is no SDK to adopt and no assumption about which agent you use.

Two ways, and your command cannot tell them apart. A pushed envelope is identical to a claimed one, so nothing about --exec changes.

Pushed. The worker holds one socket for every app you own and tells the relay how many tasks it can take, which is --max-concurrent. The relay sends at most that many, each already leased, and the worker tops the number back up as each one finishes. No request is made to fetch the work, so a task queued now starts now.

Polled. The worker also asks, every --poll-interval. This is the floor and it needs no configuration: it covers a dropped socket, a relay with push switched off, a message lost in flight, and anything queued while nothing was connected.

The two share one budget, so together they never run more than --max-concurrent children. If you want tasks handled one at a time, --max-concurrent 1 means that on both paths.

A page can ask about its own row:

GET /_hs/tasks?collection=receipts&row=r1

It answers with a status per rule: queued, working, done, failed or expired. Only for rows the caller can already read, and it never returns the prompt, the credential or the worker’s report. Enough to say “still working on it”; not enough to leak anything.

  • leaseSeconds sets how long a worker holds a task. It applies to row rules and schedule rules alike. It is the claim lease, the lifetime of the task credential, and the default when a worker extends the lease.
  • A task only happens while a worker is running. With none, tasks wait and are expired after their TTL rather than piling up forever. This is not a relay-side job runner.
  • Not available in published community templates. A template’s task would carry the publisher’s prompt, run against the installer’s data, on the installer’s machine, holding a credential the publisher scoped. An install cannot meaningfully consent to that, so publish rejects it.
  • Editing a prompt does not repair a queued backlog. The prompt is part of the rule’s identity, so changing it makes a different rule and already-queued tasks are discarded rather than re-run under the new wording. Deliberate: a task queued against one instruction should not silently execute another against a row that has moved on.

The full field table, the envelope shape and the worker contract are in the Homespun skill’s references/agent-tasks.md, which homespun skill show --section agent-tasks prints.