Store
A card's thread suits a decision, not a 400-line plan, a contract or three screenshots, and a fresh runner loses whatever stayed in the run dir. store is one command awaywork hands each file a post pins: it puts the file anywhere (Notion, a bucket, a folder) and prints a link.
store:
command: node store.mjs # runs in the config file's folder, like a custom source's command
secrets: [STORE_TOKEN] # withheld from workers, listed in Actions
read: { max_mb: 10, files: ["*.md", "*.yml", "*.png"] } # what comes back; default 10 MB of everythingThe post gets a short summary and a link per file; the next unit reading the thread gets the files back, on any machine: Files that come back.
The model never sees a URL before awaywork writes it, nor picks where a file goes. A tiny store keeping files in a folder beside it, at file:// URLs, enough for one machine:
// store.mjs: keeps each file awaywork puts in a folder next to it, at a file:// URL.
import { randomUUID } from 'node:crypto'
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const op = JSON.parse(readFileSync(0, 'utf8'))
if (op.op === 'put') {
const to = join(dirname(fileURLToPath(import.meta.url)), 'files', randomUUID(), basename(op.path))
mkdirSync(dirname(to), { recursive: true })
copyFileSync(op.path, to)
console.log(JSON.stringify({ url: pathToFileURL(to).href }))
} else {
const path = fileURLToPath(op.url)
// A pin line anyone could write names this URL: read only this store's files.
if (!path.startsWith(join(dirname(fileURLToPath(import.meta.url)), 'files', '/')))
throw new Error(`not in this store: ${op.url}`)
process.stdout.write(readFileSync(path))
}It gets one JSON op on stdin per call, with your environment, like a custom source:
{"op": "put", "item": "ENG-2101", "unit": "eng-2101", "path": "/…/units/eng-2101/out/plan.yml", "kind": "plan"}prints{"url": "…"}.pathis awaywork's copy out of the clone, readable during the put.kindisplan,contract,report, or the file's name under.away/out/, so one script can send recordings to a bucket and plans to Notion.- It may print
{"skip": true}instead, to keep a file out of the store (a 200 MB trace): the file stays in the run dir, the post goes out without a link to it, and nothing is reported. Any other output is a failed put. - A skip decides what goes to the store, never what is posted. A lean report or plan whose
report.mdis skipped still posts the step'ssummary.md, or with none the report's first five lines, with no link to the report. So a skip keeps no secret off the item. - A lean plan can't be approved or fanned out without its
plan.ymland contract. Skip either and the plan isn't posted:plan not posted: plan.yml isn't in the store.
- It may print
{"op": "get", "url": "…"}prints the bytes exactly as put. A put-only store setsget: false, and awaywork reads back with a plain HTTP GET, within the command's 10 minutes.
After each put awaywork gets the file back and compares its sha256, which the pin carries. A store that returns other bytes (a CMS adding a newline, Notion round-tripping markdown) would make every post look edited, so that file isn't pinned: report.md not stored: the store gave back different bytes than awaywork put. Keep such a page for people, the bytes somewhere that keeps them.
The script's job, not awaywork's:
- A URL that lasts. It's read back days later. An S3 presigned link expires within a week: re-sign in
getfrom a key in the URL, or pick a host whose links don't expire. - Secrets in what leaves. A log or recording can hold a token the agent saw: redact it before the script stores the file. A skip doesn't keep it off the item, and what
summary.mdsays is the skill's job. - Only its own URLs. awaywork reads pins only off its own posts and the PRs it opened for you, and on GitHub and Jira skips any someone else edited last. Asana, Linear and custom sources don't say who edited a comment, so a teammate who can edit yours can edit a pin line; a
getthat refuses URLs it didn't print, as above, stops the store fetching what they name.- With
get: falsenothing refuses. A file changed in the store no longer matches its pin and is left out of the step, but a rewritten pin line (a new URL with its own hash) passes, and awaywork fetches it at everyplan. On those sources it trusts whoever can edit the post. - A file edited after its pin never reaches a step: the edit reruns the step without it.
- With
When the store fails:
- A failed put leaves the file in the run dir, pins nothing and adds a morning report line (
fail.bin not stored: Command failed with exit code 1: …, orprinted 11 characters that aren't JSON, not {"url": "…"}, never quoting the output); post and PR still go out. - A lean report whose own
report.mdfailed posts the whole report in place of the summary, other pins kept, and says so; over 30 KB it fails like any result. - A failed get on a post the unit depends on (the one an approval covers, its own when it runs again, a PR's footer) skips the unit as
fix store, with why, counting no attempt. One on a task's result that waits or went ahead skips every task of its card the same way, a done task's result too: the card's tasks stay held until the store gives the file back. - On any other post (an older plan, a newer report) it costs only that post's files and a morning report line, so an expired
plan.ymlon an old plan stops nothing.
Files that come back
With a store, a plan or report rule's post is lean: the step writes .away/out/summary.md, a few lines for the thread (its prompt asks for it), and awaywork posts that, its own facts and a pin per file the step left, the rest in the store:
Result of story
A story in one line.
Status: done
To approve, reply with approve as the first line. Any other reply is feedback: awaywork runs this step again with it.
Pinned mock.png: file:///…/cfg/files/71e96381-7e32-4ee7-9aab-5f803b4de816/mock.png (7 bytes, sha256 2d4566582844690f8634a8b2534ea5221560038c6c0650c99140759bad603ae2)
Pinned report.md: file:///…/cfg/files/a63b0dec-63e2-4701-968e-8350ad439bae/report.md (22 bytes, sha256 8ce3d4416e83b1da658e0404e2cf700108a3ed2e43b077fadad9eb5ccaa95c8e)
— awaywork · waitsA lean plan lists its tasks (Tasks:, - api: VAT export endpoint, ...) in place of plan.yml, which is pinned with its contract and report, so only summary and task list count toward the 30 KB limit.
post: fullkeeps the whole comment, report inline, and pins only the other files.- A step with no
summary.mdgets its report's first five lines, and a morning report line. - Pins sit below everything the model wrote, right above the mark, so a
Pinnedline in a report pins nothing. - A PR step's files, screenshots say, are pinned in the PR body's footer the same way.
- Without a store, posts are full and files stay in the run dir, for the notify hook.
What comes back
Before a unit's worker starts, awaywork gets its items' pinned files into .away/context/<item>/files/<post>/, a folder per post named by its place in the thread (2-story/):
- the post an approval covers (a task's plan, the result
approved: storynames); - its own newest post when it runs again on feedback or a
continue, and every newer post; - on a PR awaywork opened for you, its footer's.
Also:
- A footer file changed since its pin is left out, with a morning report line: nothing reruns on a PR body. A stranger's PR carrying awaywork's marker gets nothing read back.
- The worker still holds no token, and its prompt calls those files data, like
item.md: someone else wrote them. - A lean plan's tasks read its
plan.ymland contract throughget; what a taskshipsstill comes from its PR's head. read.max_mbcaps what one unit reads back, 10 by default; the rest stay links, named in the morning report, and a pin's size means one over the cap isn't fetched.read.filesnarrows by name,*.mdmatchingnotes/a.mdtoo.
An edit is feedback
Every plan, run and watch poll reads back, within the cap, the files of the posts that decide a rule (a plan's, a waiting or approved report's, a task's result that waits or went ahead) and checks their hash. A mismatch makes the post stale, which counts as your feedback, whoever edited it: the step runs again, though the card didn't change, with the edited file in context and its path in files/EDITED (one per line), which its prompt calls feedback, and posts again. A rule that waits needs a fresh approve, so you only approve what awaywork has seen:
1 api#206 story done report
Full report of story.
report.md of awaywork's post of 2026-11-07T19:31:26Z changed in the store since awaywork pinned it
result posted on https://github.com/acme/api/issues/206#issuecomment-6712036203; it waits for your approveSo edit the plan in Notion freely, then approve what comes back. A full post keeps the card's rule: your edit of the plan comment counts, someone else's voids it.