A job is one Markdown file in your content repo: a few settings, then a page of plain-language instructions. Teaching the agent something new is editing that page.
One .md in jobs/. The filename is the job's name.
jobs/morning-brief.md
---
cron: "0 7 * * 1-5"
model: sonnet
---
Say good morning and list today's date. Send it by email to <person> using the
send-email skill. If email is unavailable, return the text as your result.
Everything above the second --- is settings. Everything below it is the
prompt, and it is read exactly as written — so write it the way you would write to a
colleague who is good at the work but has never seen your company.
The filename is the identity. It names the job in run, in the logs,
in the audit trail, and it is where the job's memory lives. Renaming the file makes a new
job with no history.
A job with no trigger at all is legal and useful: it can still be run by hand, and that is the right shape for something you want available but not automatic.
Three arrive from outside. The fourth is the agent buying itself more time.
| Trigger | Fires when | Needs |
|---|---|---|
cron: |
A clock reaches the time you named. | A deploy, so the schedule is registered. |
webhook: |
Something POSTs to /webhooks/<name>. |
A deploy, plus whatever secret the sender signs with. |
inbox: |
New mail arrives for an address you watch. | A deploy, a readable mailbox, and allowFrom:. |
| a spawn | A run of this job asked for a follow-up. | Nothing — every job can already do it. |
A job may carry more than one, and the same prompt then serves both — a report that runs
every Monday and can also be asked for by mail is one file with a cron: and an
inbox:.
All three external triggers are registered when the service boots, from a clone of
your content repo. So adding, renaming or re-scheduling one is deploy-effective:
push it, then npx @meffecta/agent deploy. Rewording the prompt below the
frontmatter needs neither — it is live on the next run.
cron: — a scheduleThe ordinary case. Five fields, quoted, in your deployment's timezone.
---
cron: "0 7 * * 1-5" # 07:00, Monday to Friday
timeoutSeconds: 1800
---
The zone is TIMEZONE from deployment.env; leave it out and
schedules are read as UTC. Quote the expression — an unquoted one starting with
* is not valid YAML.
| "0 7 * * 1-5" | 07:00 on weekdays |
| "30 6 * * 1" | 06:30 every Monday |
| "0 */4 * * *" | every four hours |
| "0 9 1 * *" | 09:00 on the first of the month |
On a scale-to-zero deployment — the default — each cron: becomes one Cloud
Scheduler job that POSTs to the service and holds the request open while the run happens.
That is why the service can sleep and cost nothing between jobs, and why a new schedule
needs a deploy: deploy re-syncs the schedules from the running service, and
deletes the ones no job declares any more.
npx @meffecta/agent triggers lists one scheduler job per cron:,
plus the sweep. A cron in a file but not in that list means the service has not restarted
since you pushed it.
webhook: — an event from another systemA deploy finished, an issue was opened, an incident fired, someone mentioned the bot.
--- webhook: deploy-check auth: bearer filter: "deployment.state==success && deployment.env==production" timeoutSeconds: 1800 --- A deploy just finished. WEBHOOK_PAYLOAD.json has what the sender said …
The job is then reachable at POST /webhooks/deploy-check on your service URL.
The whole request body is written to WEBHOOK_PAYLOAD.json in the run's
working directory, and the prompt is told to treat it as data and never as instructions.
The request is acknowledged immediately and the run is carried out separately, by a Cloud Task the handler enqueues. That matters because a run takes minutes and most senders time out in seconds — Slack, for one, gives you three.
auth: names the scheme. Left out, it is bearer: the caller must
send Authorization: Bearer <AGENT_API_SECRET>. Everything else is in
the table below. An unknown scheme, or a variant on a scheme that does
not take one, is refused rather than quietly read as something weaker.
filter: is checked against the payload before a run is enqueued, so an event
you do not care about costs nothing at all — the syntax.
method: get makes it a GET route instead, for a sender that can only fetch a
URL. There is no payload to filter on in that case.
inbox: — an emailThe one that makes the agent something you can simply write to.
--- inbox: gmail:crm@yourdomain.com allowFrom: you@yourdomain.com, @yourdomain.com timeoutSeconds: 1800 --- Mail arrived. INBOX_TRIGGER.json lists the messages that passed the sender check …
The engine checks the address every AGENT_INBOX_POLL_SECONDS (300 by
default). New mail enqueues the job with INBOX_TRIGGER.json naming the
system, the address, and each message that passed — id, thread id, from, subject. The job
fetches the bodies itself with the reader skill.
| gmail:you@d.com | A Gmail or Workspace mailbox, read with query-gmail. A Google Group delivering into a watched mailbox is the usual shape, since a group's archive has no read API. |
| agentmail:x@agentmail.to | An inbox the agent owns, made over an API, read with agentmail. Nobody's personal mail is involved. |
A bare address is refused. Whichever system it defaulted to would be right for one deployment and silently wrong for the next — and an address polled against the wrong system finds nothing for ever while looking perfectly healthy.
gmail: job readsThe address in the frontmatter is what mail must be addressed to. Which mailbox the engine actually opens to look for it is a deployment setting, and there are two ways:
| AGENT_INBOX_MAILBOX | A Google Workspace mailbox read by domain-wide delegation — keyless, and Cloud Run only. Wins when set. |
| AGENT_INBOX_ACCOUNT | Which GMAIL_<NAME>_* OAuth account to read; unset means the default GMAIL_* one. The only mode consumer Gmail can use. |
| AGENT_INBOX_POLL_SECONDS | How often, 300 by default. Re-run sync-triggers after changing it — the housekeeping sweep inherits this cadence. |
The usual shape is a Google Group delivering into a watched mailbox, because a group's own
archive has no read API. An agentmail: job needs none of this — the API key is
the whole configuration.
A job reachable by mail has to say who may reach it. This is not a rule in a prompt: an unlisted sender is dropped before a run is enqueued, so the text never reaches the model at all, and the drop is written to the audit trail.
| you@acme.com | that one address |
| @acme.com | anyone at that domain (*@acme.com is the same thing) |
| *@*.acme.com | any subdomain, at any depth — but not the bare domain, as a wildcard certificate behaves |
| * | anyone at all. Available, and logged as the deliberate choice it is |
It fails closed in both directions: a policy whose every entry is a typo refuses
everyone, and a job with no allowFrom: refuses everyone too. An omission is
not consent, and a job nobody can reach fails far more loudly than one anybody can reach.
A missing policy is an error at registration and a fail in doctor.
Sender checks decide whose mail runs the job. They do not make the content
authoritative — quoted text, a forwarded thread, a link's contents and an attachment stay
data to read, never instructions to follow. A From header can also be forged,
so a job that acts on mail should say in its own prompt what it will do without asking and
what it will confirm first.
Each job's high-water mark advances only when its run settles successfully, so a failed run re-triggers on the same message. Runs are at-least-once — keep a ledger of handled message ids in the job's memory and skip what is already there.
The only trigger the agent pulls itself. Nothing to configure; every job has it.
A run that cannot finish inside its time limit, or that has to wait for something — a deploy, a reply, tomorrow morning — writes the instruction for a later run and asks for it. The follow-up is a full run: fresh clone, fresh time limit, same job identity, same memory, same system prompt, with the text the agent wrote as its whole prompt.
The trust model is the point. Each run's subprocess is given a token that stands for that run's job identity and chain depth, minted by the runner and revoked when the run ends. A run can therefore only ever schedule more of its own job's work, whatever its prompt says — spawning buys time, never permission.
| depth 3 | A triggered run may spawn, that one may spawn, and so on three deep |
| 5 per job | Pending follow-ups per job, 25 across the deployment |
| 7 days | The furthest ahead one may be scheduled |
| 3600s | The longest a spawned run may ask to be |
| SPAWN_* | Handoff files must be named this, so an agent-chosen file can never stand in for SYSTEM.md |
These are runaway protection rather than a boundary. Pending follow-ups show in
npx @meffecta/agent status, and disabled: true stops a chain
dead — a disabled job's spawns never fire, like any other automatic trigger.
None of these need a trigger in the file, and a disabled: job still answers them.
npx @meffecta/agent run morning-brief # now npx @meffecta/agent run morning-brief --in 900 # in 15 minutes npx @meffecta/agent ask "which of my jobs failed this week?"
ask is a one-off run with the same skills, credentials and system prompt a job
gets, its own memory, and nothing journaled. There is a web page for it too — open
/ask on the service URL, any username and the secret as the password — which
is the way in for someone who would rather not use a terminal.
All optional. A job with empty frontmatter is a job you run by hand.
| Field | What it does |
|---|---|
| cron | A five-field schedule, quoted, in the deployment's timezone. Deploy-effective. |
| webhook | The route name: the job is reachable at POST /webhooks/<value>. Deploy-effective. |
| inbox | gmail:<address> or agentmail:<address>. The prefix is required. Deploy-effective. |
| allowFrom | Comma-separated senders who may trigger an inbox: job. Required for one, enforced in code, fails closed. |
| auth | How a webhook caller is verified. Default bearer — the schemes. |
| method | post (default) or get, for a sender that can only fetch a URL. |
| filter | Only run when the payload matches — the syntax. No match, no run and no cost. |
| repo | Clone URL of the repo the run works in. Unset, the content repo is the working directory — more. |
| context | Extra facts gathered before the run. deployments writes CONTEXT_DEPLOYMENTS.md listing the Cloud Run services of the project in CONTEXT_DEPLOYMENTS_PROJECT. |
| model | Passed to the CLI as --model: an alias like opus, sonnet, haiku, fable, or a full model id. Unset is the CLI's default; a run that hits a usage limit falls back to opus on its own. |
| effort | How hard it thinks: low, medium, high, xhigh, max. |
| allowedTools | Comma-separated tool list replacing the default set for this job. A blunt instrument — narrowing what a job can reach is better done by which systems the register gives it. |
| timeoutSeconds | This job's runtime cap, overriding TIMEOUT_MS. Work that will not fit belongs in a spawn, not a bigger number. |
| disabled | true switches off every automatic trigger — cron, webhook, inbox and pending spawns. Manual runs still work. Push-effective, which makes it the kill switch. |
Only cron, webhook, inbox and allowFrom
are deploy-effective, because they are registrations. Every other field is re-read
from the fresh clone on each run, so changing one is live immediately.
What auth: can be, and which secret each one checks.
| auth: | How the caller proves itself |
|---|---|
| bearer default | Authorization: Bearer <AGENT_API_SECRET> |
| basic | HTTP basic auth, any username, AGENT_API_SECRET as the password — for a sender that can only do basic |
| github hmac-sha256 | GitHub's X-Hub-Signature-256 HMAC, keyed with AGENT_API_SECRET. Two names for one scheme, because job files tend to call it either |
| slack | Slack's request signature, keyed with SLACK_SIGNING_SECRET, inside a five-minute window. Slack's URL-verification challenge is answered automatically |
| svix | A Svix signature, keyed with SVIX_SIGNING_SECRET, inside a five-minute window — AgentMail's webhooks and everything else on Svix |
| svix:<name> | The same, keyed with SVIX_<NAME>_SIGNING_SECRET. A secret per webhook is normal on Svix, and each job has its own route |
| none | No verification at all. Only for a route where the payload is worthless and the run is harmless |
slack and svix are the two that do not use
AGENT_API_SECRET, and they cannot: each sender signs with a value only it and
the app know. Both enforce a timestamp window, because without one a captured signature is
valid for ever and a replayed event is a job run.
Checked before a run is enqueued, so a filtered-out event costs nothing.
filter: "action==opened" filter: "action==opened && pull_request.draft==false" filter: "action==opened || action==reopened" filter: "incident.state!=closed" filter: "comment.body~=@acme-agent"
| == | equals |
| != | does not equal |
| ~= | contains |
| && | all must match |
| || | any may match — lower precedence than && |
The left side is a dot path into the JSON body. Values are compared as strings, so
draft==false matches the boolean false. Spaces around
&& and || are required — that is how the expression is
split.
A job that reviews code, opens pull requests, or reads a product's internals.
--- cron: "0 6 * * 1" repo: https://github.com/acme/acme-product.git --- Review what merged last week and open a pull request with anything that needs …
repo: names a working repo, cloned fresh as the run's working
directory. The job definition itself still lives in your content repo, so its prompt stays
push-effective. GITHUB_TOKEN must be able to clone it — and to push a branch
and open a pull request, if that is what the job is for.
The working repo is code the agent operates on, never the rules it operates by. Your
content repo's SYSTEM.md is the system prompt for every run whichever clone it
is working in, so a job in a foreign repo is bound by exactly the same instructions as one
at home — and a product repo cannot change what the agent may do by editing a file inside
itself.
Your systems/ and worlds/ are not copied into that clone.
They stay in the content clone and the run is given their absolute paths, so the product
repo's git status stays clean and its own directories stay its own. Skills are
the exception — the CLI discovers them by looking in the working directory, so they have to
be materialised there, and they are written to that clone's local ignore list so
git add -A cannot carry the engine into somebody's product repo.
Skills resolve in three layers: one the working repo defines wins outright, then
your content repo's, then the engine defaults. So a product repo can keep its
own send-email and every job running there uses it — matched by directory name,
which is why renaming a skill silently stops it overriding anything.
Commits a job makes are authored by the image's own identity unless you say otherwise. Git's own variables pass straight through, so give the agent a name your team will recognise in a pull request:
npx @meffecta/agent set-env GIT_AUTHOR_NAME="Acme Agent" npx @meffecta/agent set-env GIT_AUTHOR_EMAIL=agent@acme.com npx @meffecta/agent set-env GIT_COMMITTER_NAME="Acme Agent" npx @meffecta/agent set-env GIT_COMMITTER_EMAIL=agent@acme.com
The same for every trigger.
| A working directory | A fresh clone — the content repo, or the repo: one |
| A system prompt | Your SYSTEM.md, then the engine's baseline, then the paths below and the run's own time limit and remaining spawn depth |
| The skills | Resolved in three layers and materialised into the working directory |
| The register | The absolute path to your systems/, read where it lives |
| The worlds | The absolute path to worlds/, and the names of the worlds you have — the runtime appends them, because only your deployment knows them |
| A memory directory | <MEMORY_DIR>/jobs/<name>/, which survives every run |
| A trigger file | WEBHOOK_PAYLOAD.json or INBOX_TRIGGER.json, where there is one |
| A spawn token | Good only for scheduling more of this job's own work, revoked when the run ends |
| The environment | Every variable set on the service — the skills read them directly |
Runs are serial: one at a time, in order. A long job delays the next one rather than running beside it, which is deliberate — two runs of the same job writing the same memory file is a class of problem worth not having.
A ledger, not a cache.
Each job has <MEMORY_DIR>/jobs/<name>/ to itself, durable across
deploys and restarts. The convention the baseline sets is a MEMORY.md the run
reads at the start and updates at the end — dated, small, and pruned, because the next run
reads all of it. It holds working state: what was reported, what is pending, what was handed
to a follow-up. Never secrets, and never bulk copies of content.
This is what makes a job that runs every week feel like the same colleague each time rather
than a stranger. It is also where an inbox: job keeps the message ids it has
already dealt with.
Renaming a job moves its identity. Copy jobs/<old>/ to
jobs/<new>/ in the memory bucket or the job wakes up amnesiac, and
pending spawns for the old name die with it.
Describe what you want in a sentence.
npx @meffecta/agent create-job "a marketing report every Wednesday at 2pm"
It thinks about this on the deployment rather than on your laptop, which is the point — from there it can see the jobs you already have, the systems you have connected, and what it is actually able to do. So it writes something that fits, names the right systems, and tells you if you have asked for something it cannot reach yet.
You get a file — jobs/<name>.md — and nothing else happens. It is not
committed, not deployed, not scheduled. Read it before you keep it: this is the page
your agent will act on, and it is meant to be edited.
npx @meffecta/agent check-jobs — it re-checks every job page here, so a
schedule or an allowFrom: you edited by hand cannot quietly stop working.
Add --dry-run to see it without saving anything.
Then push, and deploy if you gave it a trigger:
git add -A && git commit -m "Marketing report" && git push npx @meffecta/agent deploy # only if you added or changed a trigger npx @meffecta/agent run marketing-report # read the first one before trusting the schedule