meffecta agent
Reference

What a job is,
and what wakes it up

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.

The file

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.

The four triggers

Three arrive from outside. The fourth is the agent buying itself more time.

TriggerFires whenNeeds
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 schedule

The 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 system

A 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.

Verifying the sender

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.

Only some events

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 email

The 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.

The system prefix is required

gmail:you@d.comA 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.toAn 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.

Which mailbox a gmail: job reads

The 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_MAILBOXA Google Workspace mailbox read by domain-wide delegation — keyless, and Cloud Run only. Wins when set.
AGENT_INBOX_ACCOUNTWhich GMAIL_<NAME>_* OAuth account to read; unset means the default GMAIL_* one. The only mode consumer Gmail can use.
AGENT_INBOX_POLL_SECONDSHow 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.

allowFrom: is required, and enforced in code

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.comthat one address
@acme.comanyone at that domain (*@acme.com is the same thing)
*@*.acme.comany 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.

A spawn — the agent's own follow-up

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 3A triggered run may spawn, that one may spawn, and so on three deep
5 per jobPending follow-ups per job, 25 across the deployment
7 daysThe furthest ahead one may be scheduled
3600sThe 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.

Running one by hand

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.

Every setting a job can carry

All optional. A job with empty frontmatter is a job you run by hand.

FieldWhat it does
cronA five-field schedule, quoted, in the deployment's timezone. Deploy-effective.
webhookThe route name: the job is reachable at POST /webhooks/<value>. Deploy-effective.
inboxgmail:<address> or agentmail:<address>. The prefix is required. Deploy-effective.
allowFromComma-separated senders who may trigger an inbox: job. Required for one, enforced in code, fails closed.
authHow a webhook caller is verified. Default bearerthe schemes.
methodpost (default) or get, for a sender that can only fetch a URL.
filterOnly run when the payload matches — the syntax. No match, no run and no cost.
repoClone URL of the repo the run works in. Unset, the content repo is the working directory — more.
contextExtra facts gathered before the run. deployments writes CONTEXT_DEPLOYMENTS.md listing the Cloud Run services of the project in CONTEXT_DEPLOYMENTS_PROJECT.
modelPassed 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.
effortHow hard it thinks: low, medium, high, xhigh, max.
allowedToolsComma-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.
timeoutSecondsThis job's runtime cap, overriding TIMEOUT_MS. Work that will not fit belongs in a spawn, not a bigger number.
disabledtrue 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.

Webhook auth schemes

What auth: can be, and which secret each one checks.

auth:How the caller proves itself
bearer defaultAuthorization: Bearer <AGENT_API_SECRET>
basicHTTP 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
slackSlack's request signature, keyed with SLACK_SIGNING_SECRET, inside a five-minute window. Slack's URL-verification challenge is answered automatically
svixA 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
noneNo 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.

Payload filters

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.

Working in another repo

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

What a run is given

The same for every trigger.

A working directoryA fresh clone — the content repo, or the repo: one
A system promptYour SYSTEM.md, then the engine's baseline, then the paths below and the run's own time limit and remaining spawn depth
The skillsResolved in three layers and materialised into the working directory
The registerThe absolute path to your systems/, read where it lives
The worldsThe 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 fileWEBHOOK_PAYLOAD.json or INBOX_TRIGGER.json, where there is one
A spawn tokenGood only for scheduling more of this job's own work, revoked when the run ends
The environmentEvery variable set on the service — the skills read them directly

Runs of one job are serial; different jobs run in parallel — three at a time by default, MAX_CONCURRENT_RUNS to change it. A job never overlaps itself, because two runs of one job writing the same memory file is a class of problem worth not having, and because a spawned run must not start beside its parent. Everything else overlaps: a run spends nearly all its life waiting on the API, so a long job no longer holds up the queue behind it.

Memory

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.

Having it write the job

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.

Then 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

Next