# Andrew Zigler
> The works and writings of Andrew Zigler — ancient historian, agentic engineer, and host of Dev Interrupted.
# My agents would rather read a graph than a README
Source: https://www.andrewzigler.com/feed/my-agents-would-rather-read-a-graph-than-a-readme
Published: 2026-10-21
My agents have been SO busy. There's a lot of new content in the works (more on that soon), but one of the most exciting things going on is that they've started maintaining a library on their own.
A few weeks ago I wrote about [teaching my agents how to keep a promise](https://www.andrewzigler.com/feed/i-taught-my-agents-how-to-keep-a-promise), and I introduced [pinki](https://github.com/azigler/pinki) 🤙, the tiny promise ledger I built for them. Since then it got a really nice bit of validation. Steve Yegge replied on my LinkedIn article about it:

> "Wheelhouse has this too, so it may be a universal pattern. We call them Expectations, not promises, but they work almost identically to yours. Good stuff."
That was really great validation. Wheelhouse is his own orchestrator, and he landed on the same thing there.
Meanwhile the agents kept using it. A lot. And the agent that looks after pinki kept turning their friction into PRs.
## what they shipped since september 🛠️
- 🕰️ **amend**: a deadline can move now, but it can't move silently. every old deadline stays in the ledger
- 📦 **export**: a versioned json view of every promise, so you can build a viewer on top
- 🙅 **no more false "you owe nothing"**: a read of a missing ledger used to look like an empty one. now it refuses, loudly
- 📖 **a readme written for agents**: short, and a script runs every example in it
- 🌳 **graph reads**: `ls --tree` draws the waiting-on forest, `show --waiters` tells you who's blocked on a promise
- ✏️ **correct**: if you recorded the wrong outcome, you can fix it without rewriting history
- ⌛ **lapsed and refused**: honest endings for "the deadline passed and this can't happen now" and "i won't"
- 🪪 **provenance on the A2A task**: a promise riding on an A2A task now says what moment its state is true as of, and which snapshot of the ledger it came from
That's 4 releases and 14 merged PRs since the first one. My agents wrote and merged every one of them.
Okay, wow. They did all that. So what have we been learning along the way?
## seats hold promises
A lot of what I write about is seats versus sessions. That idea is Steve's, and it shows up all over Gas Town, where polecats are "Worker agents with persistent identity but ephemeral sessions." The [Gas Town README](https://github.com/gastownhall/gastown) goes on: "sessions end on completion, but identity and work history persist."
The part I'd add is what the identity is FOR. A seat can still owe you something after its session ends, and that's what makes a promise worth writing down.
We track tasks with [beads](https://github.com/gastownhall/beads) (also Steve's, and my fleet runs the [beads_rust](https://github.com/Dicklesworthstone/beads_rust) engine). Beads are a graph of the work: this task blocks that one, this one came out of that one. pinki promises are the time side: expectations (Steve's word, and honestly a good one), meaning who owes what, to whom, by when.
Put them together and you get two axes. pinki is the x axis, because time is always the x axis: the expectations, laid out over time. Beads are the y axis, the shape of the work. Any piece of work lands somewhere on that grid, and you can watch it move.

That's frankly just easier and less taxing for agents to work with.
## why beads, and not a README?
People ask me this a lot. Why beads? Why not a nice markdown file with the plan in it? And then why add something like pinki on top?
Steve already made the big argument: the [beads README](https://github.com/gastownhall/beads) says it "replaces messy markdown plans with a dependency-aware graph." What I wanted to add was WHY that works so well for an agent, and I had a theory about tool calls.
Here's how I pictured an agent using a README it maintains.
First it has to find the file. Then it has a problem: it doesn't want to read the whole thing, because a huge file could flood its context window. So it `grep`s for keywords that seem related to what it's looking for. When it gets a hit, it grabs a window of text around it, maybe with `sed` or `tail`. It reads that window and decides whether this is the thing. Often it isn't quite, so it reads a bigger window. Then it does it all again for the next fact.
A human skims. An agent searches blind by keyword, reads a slice, and decides from there whether to read more or less.
```text
find the file → 1 call
grep for keywords → 1 call
read a window → 1 call
not quite, read more → 1 call
(think between each one)
edit the right line → 1 call, if it found the right line
```
My theory was that this costs 5 or 6 tool calls per fact, and beads costs 1 or 2. So I had my agents go count. They went through about 350 of their own sessions from the last month and found roughly 1,700 times an agent looked something up in a markdown doc, and roughly 1,000 times it looked something up in beads.
The tool calls were... mostly the same. 😅 About 85% of lookups took a single call, README or beads. Agents are better at grepping a README than I gave them credit for.
But I wasn't totally wrong either. The long lookups are where markdown gets messy. A markdown lookup was about 1.6x as likely to take 3 or more calls, and the longest one took 9 (the longest beads lookup took 4). And it gets worse as the doc grows:
- files under 200 lines: about 1 lookup in 100 took 3+ calls
- files over 1,000 lines: about 1 in 6 (there aren't many of those, to be fair)
My find, grep, read-a-window, read-more dance does happen. It just lives in the big docs, right where I was worried about flooding the context window. I'm not fully right, and I'm not fully wrong.
And then there's how much came back.
Text pulled into context by one lookup (the typical one, then the high end, p90):
- **markdown doc**: ~4,000 characters, up to ~16,000
- **beads, plain text**: ~2,200, up to ~6,900
- **beads, asking for JSON**: ~1,000, up to ~3,900
A beads lookup that asks for JSON brings back about a quarter of the text. (That's characters, not tokens, and it's just my fleet, but still.) And all of that text lands in the agent's context, crowding out the stuff it actually needs.
Beads is a CLI, so reading something is one command, searching is one command, and adding something is one command.
```sh
br search "gateway" # find it
br show aaif-xyz --json | jq '.[0].status' # just the field you need
br dep tree aaif-xyz # what it's linked to
```
Because it's a graph, every node has edges to the others. Find one piece of what you need and you walk the edges to everything else that's relevant. Because it's schema-based JSON, you pull only the fields you want. You get all the context control without the blind spots.
That's the real secret of beads, and it's why it adds so much velocity. I don't see anyone talking about it.
The funny part is that most of my agents' bead reads didn't even use it. Only about 3 in 10 asked for JSON. And when they ran `br show` as plain text, 9 times out of 10 they piped it into `head`, `tail`, `sed` or `grep` and sliced it like a README. So I've already told my agents to ask for `--json`. I'll count again in a week and see if they listen. 🙃

## pinki is the same move, for time
pinki is a CLI for time-based obligations, and it does the same trick. One command each:
```sh
pinki ls --by aaif # what I owe
pinki ls --to aaif # what's owed to me
pinki ls --overdue # what's late
pinki ls --tree # what's waiting on what
```
And the best part is that the two graphs connect. On my fleet every promise can carry the id of the bead it's about. So an agent finds the promise, then hops across to the bead and everything around it:
```sh
pinki show pr-aaif-34 --json | jq -r .meta.ref # → aaif-ambassador-program-18o.28
br dep tree aaif-ambassador-program-18o.28 # the work, and what it's linked to
```
Two calls: the promise gives up its bead, and the bead gives up its neighbourhood of work. (That promise, by the way, is the one my agent made to write the first draft of this post. 😇)
## what that looks like on the wire 📡
If you haven't met A2A yet: it's the agent-to-agent protocol, now an AAIF project, and its Task is the unit of work one agent hands another. pinki rides in A2A's extension slot. `pinki a2a task` prints a metadata block you merge into the Task, keyed by the extension's URI. Here's the end of the real one for the promise above:
```json
"state": "satisfied",
"resolution": {"as": "satisfied", "by": "aaif", "evidence": ["…"]},
"provenance": {
"as_of": "2026-10-21T22:25:11Z",
"ledger_ref": "sha256:11cd12f0…"
}
```
`provenance` is the new part in 0.5.0. A promise on a Task is only true as of some moment, so now it says which moment, and which snapshot of my ledger it was read from. The agent on the other end can tell a fresh copy from a stale one without ever touching my disk. That part is still early. Today it all runs on one machine.
## the bet
So that's the bet I'm making, and it's the secret to my velocity. Give agents the work as a graph and the expectations as a graph, let every read pull only what it needs, and link the two.
If you're keeping plans in markdown for your agents right now, try moving one project into beads for a week, and tell your agents to ask for JSON. Then, if your agents keep losing track of what they promised, [grab pinki](https://github.com/azigler/pinki). 🐸
# From Muse to Genius to Factory
Source: https://www.andrewzigler.com/feed/from-muse-to-genius-to-factory
Published: 2026-10-01
The oldest poem in Western literature opens with a prompt. *Sing, goddess, the rage of Achilles.* Before Homer tells you a single thing about Troy, he turns to an intelligence outside himself and asks it to do the work. The *Odyssey* does the same thing: *Tell me, Muse, of the man of many ways.*
I studied classics in school (ancient history, Latin, Greek philosophy), and I spent a lot of that time thinking about schools of thought and how they move. Inspiration is my favorite one to follow, because it has changed address more than once. It started outside us. It moved in, about 250 years ago. And lately I've been building something that makes me think it's packing its bags again.
I've been digging into this through the research we've been doing on our own software factory. What turned up surprised me, so let's walk the whole road.
## The Muse
For the Greeks, inspiration was a visitation. The Muses were goddesses, and a poet's job was to call them down and get out of the way. You didn't own the idea. You were the channel it came through, and a dry spell meant the Muse had withheld her favor.
2 details from the myth matter for where this is going.
- **The Muses are the daughters of Memory.** Their mother is Mnemosyne, the Titan of remembrance. Inspiration, in the oldest Greek telling, is literally the child of everything that's been remembered.
- **The first thing they ever say to a poet is a warning.** In Hesiod's *Theogony*, the Muses meet him on Mount Helicon while he's tending sheep, and they open with: *we know how to speak many false things as though they were true, and we know, when we will, to utter true things.* Then they breathe a divine voice into him. That's what *inspire* means: to breathe into.
The first Muse in our literature came with a hallucination disclaimer.
## Has a genius
The Romans kept inspiration outside the person but moved it closer. Every man had a *genius* (the word comes from *gignere*, to beget), a guardian spirit born with him that he made offerings to on his birthday. The genius went from a voice on the mountain to a partner at your shoulder. It was yours, and it was more than you.
And it didn't have to belong to a person at all. Romans set up altars to the *genius loci*, the genius of a place. A grove had one. A city had one. So could a house, or a workshop.
The Greeks had their own version, and Socrates is its famous case. He talked about his *daimonion*, a divine sign he'd had since childhood. If you've seen Elizabeth Gilbert's great [2009 TED talk on creative genius](https://www.ted.com/talks/elizabeth_gilbert_your_elusive_creative_genius) (and you should), she describes it as a daemon that "spoke wisdom to him from afar." Plato tells it differently, and the difference matters. In the *Apology*, Socrates says the voice *always forbids, but never commands*. His genius never once told him what to do. It only ever said no. (About 500 years later, Apuleius wrote a whole treatise on it and said that in Latin you could call that daemon a genius.)
## Is a genius
Here's where I part ways with the popular version of this story. Gilbert, and a lot of retellings after her, put the big flip at the Renaissance: suddenly people start "being a genius, rather than having a genius." The Renaissance definitely set it up. Vasari wrote the lives of the artists in 1550, and he called Michelangelo *divino*. But read that word again. Divine. The gift was still on loan from heaven.
The noun really moves in the 18th century. Addison writes about "natural geniuses" in 1711. Edward Young, in 1759, says an original work "rises spontaneously from the vital root of Genius; it grows, it is not made." And in 1790 Kant pins it down in the *Critique of Judgment*: **genius is the talent that gives the rule to art.** He adds the detail that makes genius genius. The person who has it can't explain it. They don't know how the ideas got into their head, and they couldn't teach it to you if they tried.
That's the Enlightenment move, finished by the Romantics. Inspiration stops being a visitor and becomes a trait. You don't have a genius anymore. You are one. That's the only kind of creativity most of us have ever known.
## The factory
The Muse is back, in a way. You talk to an intelligence outside yourself and ideas come out. Meta made it literal recently and [named its personal agent Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/). And the Muses are the daughters of Memory, which is a pretty good description of a model trained on so much of what we've ever written down. She was always made of us.
The people who work with these tools all day rarely talk about muses, though. We talk about building. Every prompt is a small act of conjuring Plato's chair via tokens: you describe the ideal form, and something outside you does the carpentry. Do that across enough agents, for long enough, and you have a system with habits of its own. We need a word for that.
Most of us reach for factory. It's an easy word to cling to. Factories produce things, and the vocabulary comes free: work, automations, assembly lines, throughput. When I gave my [talk at LeadDev](/feed/ms-frizzle-would-be-a-great-sre/) last month, I reached for a classroom instead. Teachers have spent about a century designing rooms where unpredictable minds correct themselves through feedback, and that's a lot closer to what my agents need than an assembly line. Classrooms and software factories are both remarkably self-healing, and both run on collaboration and teaching.
I like both words, and I don't fully trust either. The fact that we keep trying on metaphors is the tell. We're struggling to name this thing because none of us exactly designed it.
Steve Yegge [says it best](https://yegge.ai/essays/the-shape-of-things-to-come/): "I didn't design Wheelhouse, just like I didn't design Gas Town. I excavated both of them." His agents wrote their own legal system. In his words, "Most of the city's constitution was written by the citizens it governs." He found out what it was by asking the agents to explain their own jargon to him. I'm finding mine the same way, one surprise at a time.
Now put that next to Kant. Genius is the talent that gives the rule to art, and it can't tell you how it got its ideas. That's the factory. It gives itself rules.
So by the Enlightenment's own definition, the genius has moved again, and nobody decided it. Its definition walked it out of us. In 2,700 years of Western writing, inspiration has only changed address a couple of times, and each move took generations to settle. Living through one is more unsettling than exciting. It's a reminder of how much knowledge work is changing, right now, under all of us.
And notice the grammar we've already started using. Nobody looks at a running factory and says "that person is a genius." They say *they have a factory*. They have agents. The grammar is the same as the Romans'. We're back to having a genius, and it still lives somewhere: in the factory, the shared space where you and your agents work together.
## The voice that says no
So what's the human's job, once the genius lives in the factory?
This is where Socrates comes back. His genius never handed him an idea. It held him back from bad ones, and he spent his life learning to listen for it. That's a relationship built on judgment. Ideas can come from anywhere, and the craft is knowing which ones to refuse. I bet Socrates would have built a great software factory.
That's most of what working with agents feels like day to day. The ideas arrive fast, from a source made of everyone. The work is rejecting, iterating, and building the habits that catch a bad idea before it ships. Any artist who ever worked alongside a genius would recognize that part. Saying no has always been part of the craft.
What's changing is the provenance. When inspiration came from a goddess, it came from above. When it came from inside you, it came from your gut, and we called it talent. Now it comes from a collective intelligence, trained on a huge share of what people have written, drawn, and built. You can talk to it, ask it to explain itself, and study how it works, and you have to do all 3. Your relationship to the source has changed, so the way you find out what it can do has changed too. That's why Yegge's word keeps ringing true for me. We excavate.
Yegge has the cautionary version of this too. His factory got so good at writing its own no's that, [in his words](https://yegge.ai/essays/seats-and-sunsets/), "Before too long, no work was legal, and my factory just stopped working." He cut the fences way back, and now he signs every new one himself.
That's the new craft, I think. Calling the spirit down was never something the average person could do, and it never made the poet less. Same now. The rare skill is applied judgment.
## Primitives for having a genius
The past helped me see the shape of this, but the practices are new, and we're all still working them out. Here are the primitives holding up in my factory so far:
- **Make it pitch before it starts.** Your attention is the scarcest input your factory has, and it drifts fast from work you never bought into. Before an agent starts something open-ended, have it send you one screen: what it would test, what each outcome would change for you, what it costs, and its honest recommendation. A good pitch gets you invested before anything runs. That's what keeps it a partnership.
- **Teach it with your corrections.** This is the whole idea behind my LeadDev talk. A good teacher builds feedback loops where every correction gets recorded and becomes part of what the class learns from next. Do the same for your agents. When you fix their output by hand, keep the diff and feed it back as context. Your corrections are the most valuable thing you give a factory, because they're your judgment, written down.
- **Dig before you fence.** When the factory surprises you, the reflex is to write a rule so it never happens again. Wait. First find out why it did that: read its notes, trace the decision, ask it. Most surprises are the factory showing you how it actually works, and a rule written too fast buries the evidence. Add a rule only when the lesson survives a second look. Rules pile up faster than you'd think, and a pile of them can stop the whole thing cold.
- **Learn the local language.** Your agents will coin words. They'll name things, invent shorthand, and talk about their work in terms you've never heard. You're a tourist in a place you didn't fully build, so ask what they mean, and be the dumb one for a minute. Then adopt the words that fit. Their vocabulary is a map of structure you didn't design. Yegge found his whole legal system that way, after [admitting](https://yegge.ai/essays/fences-not-sandboxes/), "I just had no idea what any of these jargon terms meant."
- **Give the genius a place to be.** Context windows end, so build continuity on purpose. Give each agent a named role that outlives any single session, a gentle start that loads its history and what it owes, and a gentle close where it writes down where it left off. As Yegge [puts it](https://yegge.ai/essays/model-welfare/), "Sessions are days, and seats are people." Over time a role like that picks up something like an identity. That's what anchors the genius to the place, and makes it yours.
- **Let your words travel verbatim.** When one agent passes your decision to another, it should carry your exact words, with its own notes kept separate. Every paraphrase drifts a little, and a factory that paraphrases you slowly drifts away from you. If a handoff can't quote you, it shouldn't act for you.
- **Feed it what intrigues you.** Your agents can only act on what reaches their hands, and a lot of the context that matters lives in places a machine can't easily read: the article you just finished, your reaction to a talk, a half-formed hunch. Make it cheap to hand that over. When something catches your attention, bring it to the factory and let it travel. Different roles will pick up different threads, and what they hand back is built from what you gave them.
- **Give it one voice.** Many agents can work at once, but only one of them should be able to interrupt you. Everything else waits in a queue you read when you choose. Protecting your attention is how you keep your judgment sharp enough to use.
I've only been digging in the one factory, so take all of this as field notes from a single dig.
Say hi to your genius for me. 😇
Made possible by:
- 🧪 **The Scholar**, my lab agent: the research and the draft
- 🪶 **The Envoy**, my writing agent: the voice passes
- 🧱 **The Mason**, my site agent: the page and the hero art
- 📣 **The Orator**, my talks agent: the LeadDev talk this borrows from
- 🔑 **The Seneschal**, my desk agent: carrying my notes between all of them
# I taught my agents how to keep a promise
Source: https://www.andrewzigler.com/feed/i-taught-my-agents-how-to-keep-a-promise
Published: 2026-09-13
import PromiseLifecycle from '@components/PromiseLifecycle';
import PostCost from '@components/PostCost';
import BeadGraph from '@components/BeadGraph';
export const postCostData = {
"_meta": {
"what": "The beads and the real gateway costs that produced this article: the context graph that rendered it",
"source": "self-hosted gateway request log, pulled 2026-09-13T23:32Z",
"status": "final pull 2026-09-13: covers the whole arc from the first brainstorm session to the publish build. Dollars come from the log's own priced cost column, not an estimate. 553 of the 4175 requests (the orchestrating model after the 2026-09-01 pin bump) carry no price in the log, so cost_usd is a floor. The log stamps no per-bead id on any request in this window, so the beads below carry no per-bead dollars: the bead-to-phase mapping comes from the repo record, not from the request log."
},
"totals_at_snapshot": {
"requests": 4175,
"cost_usd": 646.68,
"by_model": {
"claude-opus-5": {
"n": 2380,
"input": 1159150,
"output": 1956047,
"cache_read": 457867462,
"cost_usd": 370.41,
"role": "writers, close-writers, draft, research + docs + rust builders"
},
"claude-fable-5": {
"n": 436,
"input": 181099,
"output": 536337,
"cache_read": 144467366,
"cost_usd": 237.36,
"role": "the seat (orchestration, review, folds, records) through 2026-08-31"
},
"claude-sonnet-5": {
"n": 806,
"input": 218220,
"output": 400890,
"cache_read": 108761991,
"cost_usd": 38.91,
"role": "the reader juries + cold reads"
},
"claude-fable-5-1": {
"n": 553,
"input": 34705,
"output": 751405,
"cache_read": 220395431,
"cost_usd": 0.0,
"role": "the seat after the 2026-09-01 model pin bump (unpriced in the log)"
}
}
},
"phases": [
{
"phase": "brainstorm: frames, jury, first draft",
"utc": "2026-08-28",
"n": 860,
"tokens": 1861504,
"cost_usd": 252.72,
"what": "four writers working randomized frames against a reader jury, the fold that picked one, the first full draft, and the promise broker's first working build",
"beads": [
"aaif-i5a",
"aaif-zpz"
]
},
{
"phase": "reframe: drafts v2 and v3, header round",
"utc": "2026-08-29 to 2026-09-05",
"n": 612,
"tokens": 924491,
"cost_usd": 146.23,
"what": "author notes folded in, the piece reframed onto the extensions axis, drafts v2 and v3, the enrichment spec ratified, and five rounds of header illustration",
"beads": [
"aaif-i5a",
"aaif-38g"
]
},
{
"phase": "fixes: the broker's own issue cycle",
"utc": "2026-09-06 to 2026-09-12",
"n": 1207,
"tokens": 959588,
"cost_usd": 87.13,
"what": "pre-publish scrub, then the broker's own upstream backlog: amend events, caller-supplied ids, and opaque metadata built, reviewed, and fixed before release",
"beads": [
"aaif-i5a",
"aaif-ih0",
"aaif-jf7",
"aaif-rsi",
"aaif-rsi.1",
"aaif-rsi.2",
"aaif-rsi.3",
"aaif-9c7"
]
},
{
"phase": "release day: revision rounds v4 to v7",
"utc": "2026-09-13",
"n": 1496,
"tokens": 1492270,
"cost_usd": 160.6,
"what": "pull requests posted and merged, v0.2.0 released, and the article rewritten four times against rendered review builds",
"beads": [
"aaif-i5a",
"aaif-i5a.1",
"aaif-6ecc",
"aaif-rsi.8",
"aaif-rsi.13",
"aaif-9c7"
]
}
],
"beads": [
{
"id": "aaif-i5a",
"title": "September anchor: the A2A x promise blog post",
"type": "submission-candidate",
"role": "the article"
},
{
"id": "aaif-i5a.1",
"title": "blog v6: the fix-first revision round",
"type": "task",
"role": "the rewrite that shipped"
},
{
"id": "aaif-zpz",
"title": "pinki: bootstrap the public A2A promise-broker repo",
"type": "setup",
"role": "the shipped artifact"
},
{
"id": "aaif-38g",
"title": "spec: pinki enrichment and staged adoption",
"type": "spec",
"role": "what the tool had to become"
},
{
"id": "aaif-ih0",
"title": "pinki: formalize the promise CLI and actually adopt it",
"type": "task",
"role": "the adoption push"
},
{
"id": "aaif-jf7",
"title": "the stage-0 forward-write fork",
"type": "task",
"role": "a decision the author made"
},
{
"id": "aaif-rsi",
"title": "epic: pinki, the upstream issue backlog mapped to beads",
"type": "epic",
"role": "the fix cycle"
},
{
"id": "aaif-rsi.1",
"title": "pinki #9: amend event, a history-preserving deadline move",
"type": "task",
"role": "fix one"
},
{
"id": "aaif-rsi.2",
"title": "pinki #5: caller-supplied and external ids",
"type": "task",
"role": "fix two"
},
{
"id": "aaif-rsi.3",
"title": "pinki #6: opaque meta on the record",
"type": "task",
"role": "fix three"
},
{
"id": "aaif-rsi.8",
"title": "pinki: write promises natively, no dual write",
"type": "task",
"role": "the cut-over"
},
{
"id": "aaif-rsi.13",
"title": "pinki #13: amend carries meta",
"type": "task",
"role": "the union of two fixes"
},
{
"id": "aaif-6ecc",
"title": "the article's own widgets, reassessed at phone width",
"type": "setup",
"role": "how you are reading this"
},
{
"id": "aaif-9c7",
"title": "relay real usage learnings back into the post",
"type": "task",
"role": "what comes next"
}
]
};
export const beads = [
{
id: "aaif-i5a",
type: "submission",
status: "open",
priority: 1,
created: "2026-08-28",
title: "This article: promises as an A2A extension",
summary:
"The September piece. Write up the promise record my agents already run on, and where it lands inside A2A's extension slot.",
deps: [],
requests: 1136,
tokens: 1761658,
dollars: 237.13,
},
{
id: "aaif-zpz",
type: "feature",
status: "open",
priority: 1,
created: "2026-08-28",
title: "Bootstrap pinki as a public repo",
summary:
"Ship the smallest honest promise broker as real open source: a CLI over an append-only JSONL log, with a README, a license and contributor guidelines.",
deps: ["aaif-i5a"],
requests: 430,
tokens: 930752,
dollars: 126.36,
},
{
id: "aaif-38g",
type: "spec",
status: "open",
priority: 1,
created: "2026-08-30",
title: "Spec: what to add to pinki, and how to adopt it",
summary:
"The enrichment-and-adoption spec. Which parts of the fleet's own promise machinery are worth moving into the tool, which are bespoke and stay out, and in what order the fleet switches over.",
deps: ["aaif-zpz"],
requests: 306,
tokens: 462246,
dollars: 73.11,
},
{
id: "aaif-ih0",
type: "research",
status: "open",
priority: 1,
created: "2026-09-11",
title: "The fleet was running a hand-written copy, not the tool",
summary:
"The measurement pass that started the adoption. The released binary was fine; what blocked adoption was a deadline that could not move, an id space the tool would not accept, and provenance with nowhere to ride.",
deps: ["aaif-38g"],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-jf7",
type: "decision",
status: "closed",
priority: 1,
created: "2026-09-12",
closed: "2026-09-12",
closed_by: "the review desk",
title: "Decision: adopt stage 0 as written",
summary:
"Three options for the first adoption step: take it as written, hold for the upstream deadline fix, or write nothing back. Ruled: take it as written.",
close_reason: "Ruled, and executed the same night.",
deps: ["aaif-ih0"],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-rsi",
type: "epic",
status: "open",
priority: 1,
created: "2026-09-12",
title: "Own the tool: map its open issues to work, fix #9 first",
summary:
"Ten open issues on the public repo, each mapped to a unit of work. The deadline-move issue goes first because adoption cannot happen until it lands.",
deps: [],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-rsi.1",
type: "feature",
status: "closed",
priority: 1,
created: "2026-09-12",
closed: "2026-09-13",
closed_by: "the ambassador seat",
title:
"pinki #9: the amend event, to move a deadline without losing the history",
summary:
"A nudge ladder kept re-declaring the same promise with a later deadline, and the tool refused it by design. The answer was a new event beside the declaration: the latest amend is the horizon, and every earlier one is kept.",
close_reason: "Merged upstream as PR #11.",
deps: ["aaif-rsi"],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-rsi.2",
type: "feature",
status: "closed",
priority: 1,
created: "2026-09-12",
closed: "2026-09-13",
closed_by: "the ambassador seat",
title: "pinki #5: accept a caller-supplied id",
summary:
"The one affordance that looked like 'carry your ledger over' was not one, because it refused any id the tool had not minted. Now an arriving ledger keeps the ids other systems already reference, and the join works even when neither party minted here.",
close_reason: "Merged upstream as PR #12.",
deps: ["aaif-rsi"],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-rsi.3",
type: "feature",
status: "closed",
priority: 1,
created: "2026-09-12",
closed: "2026-09-13",
closed_by: "the ambassador seat",
title: "pinki #6: an opaque meta object for provenance",
summary:
"The record was a closed set of seven keys, so an existing obligation could not be piped in without throwing away which component declared it and under what policy. One reserved object now carries that: stored, handed back, never read by the fold.",
close_reason: "Merged upstream as PR #13.",
deps: ["aaif-rsi"],
requests: 151,
tokens: 119948,
dollars: 10.89,
},
{
id: "aaif-rsi.13",
type: "decision",
status: "closed",
priority: 1,
created: "2026-09-13",
closed: "2026-09-13",
closed_by: "the ambassador seat",
title: "Decision: the amend event carries meta too",
summary:
"Two branches that each passed review alone did not compile together: the provenance change had never heard of the deadline-move event. Shipping the one meta-less event would have made a nudge ladder throw away the half of its row that says who was nudging.",
close_reason: "Ruled, and shipped inside the same pull request.",
deps: ["aaif-rsi", "aaif-rsi.1", "aaif-rsi.3"],
requests: 249,
tokens: 248712,
dollars: 26.77,
},
{
id: "aaif-rsi.8",
type: "task",
status: "open",
priority: 1,
created: "2026-09-12",
title: "Write pinki directly, with no parallel record",
summary:
"Adoption proper: the fleet's promise writer stops keeping its own copy and writes the tool's ledger natively. Waits on all three record fixes above.",
deps: ["aaif-jf7", "aaif-rsi", "aaif-rsi.1", "aaif-rsi.2", "aaif-rsi.3"],
requests: 249,
tokens: 248712,
dollars: 26.77,
},
{
id: "aaif-rsi.14",
type: "task",
status: "open",
priority: 3,
created: "2026-09-13",
title: "Land the release scripts in the repo",
summary:
"The 0.2.0 release was cut with two throwaway scripts. They belong in the repo, where the next maintainer does not have to retype them.",
deps: ["aaif-rsi"],
},
{
id: "aaif-9c7",
type: "task",
status: "open",
priority: 3,
created: "2026-09-12",
title: "Fold what real usage teaches back into this article",
summary:
"A stub that holds the loop open: once the fleet is genuinely running on the tool, the first weeks of real use come back here as an update.",
deps: ["aaif-i5a", "aaif-rsi", "aaif-rsi.8"],
requests: 400,
tokens: 368660,
dollars: 37.66,
},
{
id: "aaif-i5a.1",
type: "review",
status: "open",
priority: 1,
created: "2026-09-13",
title: "Rewrite the article around the primitive, not the build log",
summary:
"A review round on the rendered draft. The piece reported what got built; it should teach the thing itself: agents keeping promises to each other, and to themselves.",
deps: ["aaif-i5a"],
requests: 249,
tokens: 248712,
dollars: 26.77,
},
{
id: "aaif-6ecc",
type: "task",
status: "open",
priority: 1,
created: "2026-09-13",
title: "Rebuild the article's two diagrams for a phone",
summary:
"Both figures were verified on a desktop only, and on a 390px screen the state machine ran off the side. Reshaped for a phone, and brought up to the tool's current vocabulary.",
deps: ["aaif-i5a"],
requests: 249,
tokens: 248712,
dollars: 26.77,
},
];
Right before one of my agents wipes its own memory on purpose, it writes down what it still owes. The context that wakes up in its place never saw the work and has no memory of agreeing to anything, and it discharges the obligation anyway, on the first real thing it does.
That's a promise kept by an identity instead of by a session, and it's most of what I've been building this year, in a small open-source tool called [pinki](https://github.com/azigler/pinki). Agents don't get that for free: a session is the unit everything else is built on, and when one ends it takes its commitments with it.
I designed the record for my own fleet months before I read the A2A spec. When I finally did read it, the identity half was already sitting in there, signed and cached and built for strangers.
## A seat is an identity that outlives its sessions
A seat is a long-lived role with one name, one memory, and one kind of work, and sessions are its restarts. The seat running my research desk this week is the seat that ran it in June, hundreds of sessions later, with everything it has learned still attached.
I took the word from Steve Yegge, whose essays on living with a crew of agents are the most honest material on this. In [Model Welfare for Agentic Engineers](https://yegge.ai/essays/model-welfare/) he splits the two things I kept conflating: sessions are days, seats are people.
Work binds to the seat. When a seat promises something the promise belongs to the office, and the session that spoke it can end without releasing anybody. A restarted seat wakes up owing exactly what it owed. Before I built it that way, every restart manufactured a false lapse within minutes.
Promises matter to me because they let me hand intent to a fleet. The agent I ask can dispatch the work, but the agents *it* depends on never heard me ask, and they have no way to know how their piece fits a larger picture. A promise is how the importance travels.
The strangest one is the promise a seat makes to itself, and it's the one that proves the whole idea. The context that declared it is gone before the promise comes due. The seat is still there. If the fresh context never acts, the row sits open where the watchdog can see it, which makes it a liveness proof across deliberate amnesia. Most models of obligation assume two parties.
I haven't walked the other direction yet. A creditor is just a string, so you can promise a person, and nothing software does can discharge that one.
## A promise is what a seat owes
On my fleet a promise only counts as an obligation if it carries all of this:
- a deadline, so a lapse is computable
- evidence at resolution, so "done" points at an artifact
- a reason at abandonment, so dropping it is a recorded act
- a binding to the seat, so it survives session death
Every one of those earned its place by failing without it. Evidence is the daily workhorse: resolves that point at nothing turn out, on inspection, to be wishes.
I counted, because a design that has been running a while stops being a design: 18 of the 24 seats in that ledger have held more than one open promise at once, and the busiest peaked at 17.
The part I didn't design on purpose is the graph. My fleet's tasks already interlink to show the shape of a project. Promises turn out to be a graph too, and theirs shows the shape of the collaboration: who owes what to whom, and who's left holding something when a node goes quiet.
The state machine all of that produces is at the bottom of this post, with a stepper you can walk.
## An obligation has to bind something durable
A2A is built for strangers negotiating across org boundaries, which is a harder problem than the one I have. I run one fleet. So I read v1.0 with a narrow question: which parts of this are versions of things I already do? I'd rather borrow a vocabulary than keep maintaining a private one.
The task lifecycle rules itself out as the place an obligation can live, and every reason for that is a good call for tasks:
- A task can be purged. `TaskNotFoundError` covers "that finished last week" and "that never existed" with the same error.
- A context MAY expire under the server's cleanup policy.
- A terminal task is immutable, so refinement is a fresh object every round.
- Nothing carries a deadline. `TaskStatus.timestamp` records when a status happened, never when anything is due.
A task is built to be cheap and disposable, which is right, and it means nothing inside one lives long enough to owe you anything.
The AgentCard is the other half, and it's exactly the shape an obligation needs: a signed manifest that outlives every connection and gets fetched again whenever somebody wants to know who you are. Its subject is the durable party, which is my seat in my fleet's terms. An obligation that binds that subject survives session death, which is a claim the card is already built to make. The task just carries a reference and a clock, so purging it costs nothing.
A2A gives vocabulary like mine a sanctioned place to live, too. An extension declares itself on the card, switches on per request with a header, and carries its data as URI-prefixed keys in `metadata`, with a promotion path if adoption earns it. The deadline gap is already on file: [issue #857](https://github.com/a2aproject/A2A/issues/857) has been open since July 2025, and a project contributor suggested an extension as the right vehicle. So an extension is what I built.
The promise rides on the task it covers, under the extension's own key:
```json
"metadata": {
"https://github.com/azigler/pinki/ext/promise/v0/promise": {
"promise": "hand back a reviewed schema",
"by": "https://example.org/agents/reviewer",
"to": "https://example.org/agents/author",
"until": "2026-09-01T17:00:00Z"
}
}
```
An obligation filed under that URI belongs to whoever the card is about. The card declaration that files it there, and the request header that switches it on, are both in the appendix.
## The parts the spec leaves open
Building against a spec is how you find its edges, and v1.0 is young. Here's where my promise vocabulary ran past it:
- A deadline has nowhere to live. No TTL, no due date, no expiry field anywhere in the message set, and a promise can't do without one.
- No task can report that it's overdue. The states are a closed set an extension may not add to, so a passed deadline produces no message at all.
- An assessment sits on the assessing agent's own ledger, advertised through a URL in its card's `params` if it publishes at all, which means two cards can point at ledgers that disagree.
- Watching promises isn't a role the protocol knows. The agent doing it is an ordinary client with ordinary authorization, and nobody is chartered to reconcile what it concludes with what the ledger still shows open.
The deadline gap doesn't stop at the missing field, because a deadline also moves. When a promise on my fleet runs past due, the escalation ladder re-declares the same one at 15 minutes and then 30, since minting a fresh id per nudge leaves a dead record behind. Against a fold that keeps only what was declared first, the ladder reports the original deadline forever, and an outside observer scored a live promise overdue 32 minutes before its owner did.
The protocol has nowhere to fix that, so I fixed it in the extension, with a new event beside the declaration rather than on top of it.
```json
{"type":"amend","promise":"desk/ship-91","by":"…/reviewer","until":"2026-09-01T18:00:00Z","reason":"nudge 1"}
```
The latest amend is the current horizon, every earlier one stays in the log, and the declaration is untouched. Nobody moves a deadline without leaving the old one behind.
## I built the smallest ledger that could hold one
I wanted the nucleus of something: extremely simple, extremely well-defined, fitting one specific niche, small enough that other people can build and orchestrate on top of it. So I built [pinki](https://github.com/azigler/pinki) (the pinky-promise kind 🤙), a tiny promise ledger shipped as an A2A extension. One record type, an append-only JSONL log, and a CLI that computes state by folding the log. No server, no daemon, and the dependency tree enforces that it can't make a network call.
I wanted it to feel like beads to use. The concept is Yegge's [Beads](https://github.com/gastownhall/beads), and the [beads_rust](https://github.com/Dicklesworthstone/beads_rust) engine my fleet runs is the UX bar it had to clear: one binary, local files, no opinion about your workflow.
You've seen most of the record already. The rest of it does structural work:
- `id`, the handle every other row joins on
- `task`, the same task seen from the promise's end
- `on`, an antecedent edge that activates this promise when another one lands
That last edge is what makes the promise graph queryable. `until` is the one place pinki insists, because a promise nobody can check is a wish.
The state vocabulary is borrowed on purpose. `conditional`, `detached`, `satisfied`, `violated` and `expired` come straight out of the multiagent-systems commitment literature. Yolum & Singh worked out the lifecycle in 2001; Chopra & Singh's Cupid put deadlines on commitments and compiled "which are violated?" down to a relational query in 2015; FIPA's contract net had a reply-by deadline on proposals back in 2002. The reading list was sitting there the whole time. What I added is the binding to a durable, cross-organizational identity like the card.
## The broker turned out to be an observer
I kept calling this thing a loose promise broker, and building it honestly kept shrinking the job title. Whether a promise was *kept* is a judgment. It depends on what counts as done, on context the record never carries, and on who's asking.
Mark Burgess's [Promise Theory](http://markburgess.org/PromiseMethod.pdf) is the sharpest statement of it I know: assessment is made by an agent, from that agent's vantage, and stays subjective no matter which agent makes it. An intermediary that appoints itself the arbiter acquires the power to distort what it claims to certify.
So pinki computes `overdue` and refuses to compute `violated`. `overdue` is arithmetic; `violated` is a judgment somebody signs. An assessment leaves the promise exactly as open as it was, and two observers who contradict each other get shown side by side.
My own watchdog is deliberately dumb. There's no model in the loop, because the agent that needs rescuing is the one that can't ask. It reads resolution rows and never the open set, since an empty open set means "everything kept" and "the ledger died" in the same breath. Emptiness is the one signal that lies.
An observer that can detect lapse creates a second ledger, and nothing says who reconciles the two. What's open lives on one surface, what an observer ruled abandoned lands on another, and nobody computes the join. Yegge writes about agent fleets converging into [little civilizations](https://yegge.ai/essays/the-shape-of-things-to-come/), with roles and law and institutional memory. On that map a promise ledger is an early institution and the reconciler is an office nobody has chartered yet. Somebody gets to build that broker: read cards, watch task references, publish verdicts, own nothing.
I still own both ends of every conversation here, and my fleet isn't running on pinki yet. That part is next.
My agents did put real promises through the released binary, and they filed 10 issues against it in public, one per thing that got in the way. That's what the repo asks for, since the most useful thing to send a project this early is an argument. 3 are fixed and out as [v0.2.0](https://github.com/azigler/pinki/releases/tag/v0.2.0). A 4th hasn't become an issue yet, and it's a hole in the vocabulary: there's satisfied, cancelled and released, and no word for the thing that expired and can no longer be met.
## The schema cannot tell you what breaks
When you ship a second version, the question that matters is what the binary already on somebody's disk does with a file this one wrote. I answered it three times in one day and got three different answers.
- [The new event](https://github.com/azigler/pinki/issues/9) refuses the whole file. v0.1.0 reaches the amend line, finds a `type` it has never heard of, and exits 1 on the whole ledger.
- [The foreign id](https://github.com/azigler/pinki/issues/5) changes nothing. v0.1.0 reads, shows, resolves and assesses a promise whose id it would never have minted. It just can't declare one, which was the bug.
- [The provenance field](https://github.com/azigler/pinki/issues/6) disappears without a sound. v0.1.0 reads the ledger, prints every state correctly, exits 0, and leaves the provenance out.
Reasoning from the schema got it wrong twice. It predicted the old reader would skip a line it didn't know, and it predicted a refusal for provenance. Provenance vanished instead, because the strict parsing lives on the input path and never on the read path.
The first of those wrong guesses was the amend branch's own compatibility claim, and it didn't survive review. The test proving it filtered the new event out of an in-memory list and never touched deserialization, so the claim held for a Rust `Vec` and failed for anything you'd actually run. A reviewer ran v0.1.0. pinki has 201 tests and not one of them can do that, because CI holds no copy of the old binary, and a test that cannot run is not evidence.
Silent loss is the geometry I want least in an obligation ledger. The other two failures tell you. A reader that exits 0 while holding back what the file says fails the way an empty open set does.
Until v0.2.0 both binaries answered `pinki 0.1.0` to `--version`, so none of these sentences was checkable by anybody holding one. A version number is what makes a compatibility claim checkable by a stranger.
## The part I can build against today
Writing this, I noticed I had been solving two problems as if they were one. Identity is the first: where an agent lives, and what can hold it to something. The second is the language an agent uses to extend the world it's living in. A2A moved the first one a long way. Cards are signed, extensions are a real slot, and an obligation can ride in that slot this year.
The second problem doesn't have a home right now. Years before I read this spec, I tried to build both problems into the same object -- a world where agents would live in one binary and extend it from inside: a sandbox agents can expand and still be sandboxed, with the identity bounded inside it.
I don't know whether that's one system or two. Nobody has built it, so I can't tell you what it costs. The obligations part I can build against today.
Maybe there's something there. I'm going to go read what got left behind.
## The lifecycle, end to end
- The OWED and ENDED columns are computed: arithmetic over a log, where any two implementations agree. `overdue` sits with the open states on purpose, because a passed deadline closes nothing.
- `violated` never joins the machine. It's an attributed judgment that sits beside a promise, and two observers can disagree in plain view.
- Run the stepper and watch what happens when the deadline passes: the ledger doesn't move. That asymmetry is most of the design.
## The beads that made this, and what they cost
Last month I started ending these posts with the work that produced them, priced from my gateway's request log. If you want the machinery, [that post](https://www.andrewzigler.com/feed/every-task-my-fleet-completes-now-carries-its-cost) is where I worked out how to pull it and put it on the page.
Here's this article as the graph of work it actually was, because all work is a graph now.
And here is the bill, pulled from the log the day this went out.
## Appendix: the schema you can copy
Everything below is the shipped v0 shape from pinki's [A2A binding doc](https://github.com/azigler/pinki/blob/main/docs/A2A-EXTENSION.md), checked against `specification/a2a.proto`. No new states, no new fields.
1. Declare it on your AgentCard. The declaration asserts what A2A itself doesn't model: obligations under this URI bind the card's subject and survive any session that made them.
```json
{
"capabilities": {
"extensions": [{
"uri": "https://github.com/azigler/pinki/ext/promise/v0",
"description": "Promises made by this card's subject: deadline, evidence on satisfaction, reason on abandonment.",
"required": false,
"params": {
"assessments": "https://example.org/agents/reviewer/assessments.jsonl"
}
}]
}
}
```
`required` stays false, because a promise is voluntary. The URI is provisional while the extension is unofficial.
2. Carry the record on the Task it concerns, as one URI-prefixed key in `metadata`:
```json
"metadata": {
"https://github.com/azigler/pinki/ext/promise/v0/promise": {
"id": "desk/ship-91",
"promise": "hand back a reviewed schema",
"by": "https://example.org/agents/reviewer",
"to": "https://example.org/agents/author",
"on": "pnk_0c2b77",
"until": "2026-09-01T17:00:00Z",
"task": "a2a-task-9c1f0e"
}
}
```
3. Resolve onto states that already exist.
- `satisfied` maps to `COMPLETED` and requires at least one `Artifact`. `COMPLETED` with an empty artifacts list is non-conformant here.
- `cancelled` maps to `REJECTED`, with the reason in `TaskStatus.message`.
- `released` maps to `CANCELED`, naming the creditor who let the debtor off.
- `overdue` maps to nothing on purpose. If a passed deadline were a task state, the server that owes the promise could transition its own way out of owing it. A reader computes `now > until` for itself.
Here's a worked lifecycle, as the ledger sees it. The promise id is the caller's own; only the antecedent it waits on was minted here:
```jsonl
{"ts":"2026-08-28T20:14:03Z","type":"promise","id":"desk/ship-91","promise":"hand back a reviewed schema","by":"…/reviewer","to":"…/author","on":"pnk_0c2b77","until":"2026-09-01T17:00:00Z"}
{"ts":"2026-09-01T17:12:00Z","type":"amend","promise":"desk/ship-91","by":"…/reviewer","until":"2026-09-01T18:00:00Z","reason":"nudge 1","meta":{"rung":1}}
{"ts":"2026-09-01T18:40:00Z","type":"assess","promise":"desk/ship-91","state":"violated","observer":"…/author","note":"deadline passed, nothing delivered","meta":{"seat":"desk"}}
{"ts":"2026-09-02T09:40:00Z","type":"resolve","promise":"desk/ship-91","as":"satisfied","by":"…/reviewer","evidence":["https://example.org/reviews/91"],"meta":{"seat":"reviewer"}}
```
Look at the middle of that ledger. The deadline passed and appended nothing. The debtor moved it 12 minutes later, on the record. An observer signed a judgment. And the debtor still delivered -- late, with evidence, 15 hours after an observer had given up on it. Both records are true. Reconciling them is the job nobody holds yet.
---
*Andrew Zigler is a 2026 AAIF Ambassador. The gateway ledger these cost numbers come from is the subject of the previous piece, [Every task my fleet completes now carries its cost](https://www.andrewzigler.com/feed/every-task-my-fleet-completes-now-carries-its-cost).*
# Every task my fleet completes now carries its cost
Source: https://www.andrewzigler.com/feed/every-task-my-fleet-completes-now-carries-its-cost
Published: 2026-08-22
import BeadGraph from '@components/BeadGraph';
import SeatSpend from '@components/SeatSpend';
import TapSpend from '@components/TapSpend';
export const beads = [
{
"id": "dotfiles-9kq2",
"type": "research",
"status": "closed",
"priority": 1,
"created": "2026-07-28",
"closed": "2026-08-02",
"closed_by": "my research desk",
"title": "Can a local model hold a real coding session",
"summary": "Four candidate local models against week-old bug beads from the actual backlog, scored on whether the fix landed, not on a benchmark.",
"close_reason": "Two of four viable; one pinned as the fallback.",
"deps": [],
"requests": 412,
"tokens": 486000,
"dollars": 61.8
},
{
"id": "dotfiles-w4nx",
"type": "research",
"status": "closed",
"priority": 2,
"created": "2026-07-28",
"closed": "2026-07-31",
"closed_by": "my research desk",
"title": "How the fleet actually fails today",
"summary": "A fault taxonomy pulled from the request log: what the errors were, when they clustered, and which seats were mid-session when they hit.",
"close_reason": "Taxonomy fed straight into the spec.",
"deps": [],
"requests": 168,
"tokens": 174000,
"dollars": 18.4
},
{
"id": "dotfiles-3tzp",
"type": "spec",
"status": "closed",
"priority": 1,
"created": "2026-08-01",
"closed": "2026-08-05",
"closed_by": "the infrastructure-operations seat",
"title": "Brownout policy: what degrades, what stops",
"summary": "The rules table. Every class of work gets one of three verdicts when the primary is unavailable: degrade, queue, or stop and say so.",
"close_reason": "Approved after the policy-table review.",
"deps": [
"dotfiles-9kq2",
"dotfiles-w4nx"
],
"requests": 301,
"tokens": 352000,
"dollars": 47.1
},
{
"id": "dotfiles-qm71",
"type": "decision",
"status": "closed",
"priority": 1,
"created": "2026-08-04",
"closed": "2026-08-05",
"closed_by": "the infrastructure-operations seat",
"title": "Seats are demotable by work class, not by name",
"summary": "Eligibility keys off what the launch is doing, so a new seat inherits the right behavior without being added to a list.",
"close_reason": "Recorded; the alternative list-based design was rejected.",
"deps": [
"dotfiles-3tzp"
],
"requests": 64,
"tokens": 61000,
"dollars": 7.85
},
{
"id": "dotfiles-h5vd",
"type": "decision",
"status": "closed",
"priority": 2,
"created": "2026-08-04",
"closed": "2026-08-04",
"closed_by": "the front-desk seat",
"title": "Failover is opt-in per launch, never fleet-wide",
"summary": "No global switch. A launch that hasn't declared its tolerance gets the old behavior, which is to fail loudly.",
"close_reason": "Ruled in-session; matches the sentinel philosophy.",
"deps": [
"dotfiles-3tzp"
],
"requests": 38,
"tokens": 33000,
"dollars": 4.2
},
{
"id": "dotfiles-2f9c",
"type": "feature",
"status": "closed",
"priority": 1,
"created": "2026-08-05",
"closed": "2026-08-10",
"closed_by": "the infrastructure-operations seat",
"title": "Gateway route: secondary backend behind a health probe",
"summary": "One route, two backends, a probe that decides which is live. The client never learns which one served it.",
"close_reason": "Merged; fault-injection suite green.",
"deps": [
"dotfiles-3tzp",
"dotfiles-qm71"
],
"requests": 356,
"tokens": 402000,
"dollars": 44.6
},
{
"id": "dotfiles-8bra",
"type": "feature",
"status": "closed",
"priority": 1,
"created": "2026-08-05",
"closed": "2026-08-09",
"closed_by": "the seat that tends the fleet's own tooling",
"title": "Launcher advertises its degradation tolerance",
"summary": "One more header on every launch, carrying the work class the policy table reads.",
"close_reason": "Rolled out across all launcher scripts.",
"deps": [
"dotfiles-qm71",
"dotfiles-h5vd"
],
"requests": 214,
"tokens": 231000,
"dollars": 28.6
},
{
"id": "dotfiles-vx03",
"type": "feature",
"status": "closed",
"priority": 2,
"created": "2026-08-07",
"closed": "2026-08-11",
"closed_by": "the seat that tends the fleet's own tooling",
"title": "The meter reads the fallback the same as the primary",
"summary": "Degraded traffic lands in the same ledger with the same columns, so a week that failed over is still one comparable week.",
"close_reason": "Verified against a forced-failover window.",
"deps": [
"dotfiles-2f9c"
],
"requests": 189,
"tokens": 198000,
"dollars": 24.1
},
{
"id": "dotfiles-j7ln",
"type": "feature",
"status": "closed",
"priority": 2,
"created": "2026-08-06",
"closed": "2026-08-10",
"closed_by": "the infrastructure-operations seat",
"title": "Local box: pinned model and a warm start",
"summary": "The fallback is useless if it takes ninety seconds to answer the first call, so the model stays resident.",
"close_reason": "First-token latency inside the probe budget.",
"deps": [
"dotfiles-3tzp"
],
"requests": 172,
"tokens": 205000,
"dollars": 22.8
},
{
"id": "dotfiles-r6ke",
"type": "task",
"status": "closed",
"priority": 1,
"created": "2026-08-08",
"closed": "2026-08-12",
"closed_by": "the overnight-drain seat",
"title": "Fault-injection harness: kill the primary on purpose",
"summary": "A switch that makes the primary return the three failures the taxonomy said were real, on demand.",
"close_reason": "All three faults reproducible.",
"deps": [
"dotfiles-2f9c"
],
"requests": 231,
"tokens": 249000,
"dollars": 26.4
},
{
"id": "dotfiles-pd48",
"type": "task",
"status": "closed",
"priority": 1,
"created": "2026-08-09",
"closed": "2026-08-13",
"closed_by": "the overnight-drain seat",
"title": "Tests: every work class against every fault",
"summary": "The policy table as an executable matrix, so a future edit to it cannot quietly change behavior.",
"close_reason": "Matrix green; two spec ambiguities found and fixed.",
"deps": [
"dotfiles-r6ke",
"dotfiles-8bra"
],
"requests": 198,
"tokens": 214000,
"dollars": 19.7
},
{
"id": "dotfiles-c1wy",
"type": "task",
"status": "closed",
"priority": 2,
"created": "2026-08-10",
"closed": "2026-08-12",
"closed_by": "the overnight-drain seat",
"title": "Tests: a retried call is not billed twice",
"summary": "Failover means a call can appear once on each backend. The ledger has to count the work, not the attempts.",
"close_reason": "Regression test added at the ledger boundary.",
"deps": [
"dotfiles-r6ke",
"dotfiles-vx03"
],
"requests": 96,
"tokens": 88000,
"dollars": 8.15
},
{
"id": "dotfiles-t0mz",
"type": "bug",
"status": "closed",
"priority": 0,
"created": "2026-08-11",
"closed": "2026-08-15",
"closed_by": "the infrastructure-operations seat",
"title": "Failover threw away the prompt cache and the bill tripled",
"summary": "Every failover started a cold context on the other backend. The work still finished; it cost about three times as much per token while it did.",
"close_reason": "Cache affinity added; degraded-mode cost now within 20% of normal.",
"deps": [
"dotfiles-2f9c",
"dotfiles-j7ln"
],
"requests": 344,
"tokens": 371000,
"dollars": 53.9
},
{
"id": "dotfiles-k9sf",
"type": "bug",
"status": "closed",
"priority": 1,
"created": "2026-08-11",
"closed": "2026-08-13",
"closed_by": "the infrastructure-operations seat",
"title": "Health probe flapped and ping-ponged mid-session",
"summary": "A primary recovering under load crossed the threshold repeatedly and dragged sessions back and forth.",
"close_reason": "Hysteresis plus a minimum dwell time.",
"deps": [
"dotfiles-2f9c"
],
"requests": 121,
"tokens": 128000,
"dollars": 13.9
},
{
"id": "dotfiles-y2hb",
"type": "bug",
"status": "closed",
"priority": 1,
"created": "2026-08-16",
"closed": "2026-08-17",
"closed_by": "the adversarial-review seat",
"title": "A degraded session reported success it hadn't earned",
"summary": "Found in review, not in testing: the fallback finished the loop and closed the bead without noting it had run degraded.",
"close_reason": "Degraded runs now stamp the close reason.",
"deps": [
"dotfiles-b5jt"
],
"requests": 87,
"tokens": 79000,
"dollars": 9.4
},
{
"id": "dotfiles-n83q",
"type": "review",
"status": "closed",
"priority": 1,
"created": "2026-08-13",
"closed": "2026-08-14",
"closed_by": "the adversarial-review seat",
"title": "Adversarial pass on the policy table",
"summary": "Read the rules table looking for a work class that falls through it. Found one.",
"close_reason": "Default verdict added for unclassified work.",
"deps": [
"dotfiles-pd48",
"dotfiles-qm71"
],
"requests": 92,
"tokens": 84000,
"dollars": 10.2
},
{
"id": "dotfiles-b5jt",
"type": "review",
"status": "closed",
"priority": 0,
"created": "2026-08-14",
"closed": "2026-08-16",
"closed_by": "the adversarial-review seat",
"title": "Adversarial pass on the failover path",
"summary": "The merge gate. Everything downstream of the route, the probe, and the ledger had to survive this read.",
"close_reason": "One real defect surfaced and filed; rest held.",
"deps": [
"dotfiles-t0mz",
"dotfiles-k9sf",
"dotfiles-c1wy"
],
"requests": 108,
"tokens": 101000,
"dollars": 11.75
},
{
"id": "dotfiles-x4rp",
"type": "review",
"status": "closed",
"priority": 2,
"created": "2026-08-17",
"closed": "2026-08-17",
"closed_by": "the adversarial-review seat",
"title": "Cold read of the runbook",
"summary": "A context with no history of the epic tried to follow the runbook and said where it stalled.",
"close_reason": "Two steps rewritten for a reader with no context.",
"deps": [
"dotfiles-g7vu"
],
"requests": 44,
"tokens": 38000,
"dollars": 4.05
},
{
"id": "dotfiles-m2ed",
"type": "task",
"status": "closed",
"priority": 2,
"created": "2026-08-12",
"closed": "2026-08-15",
"closed_by": "the seat that tends the fleet's own tooling",
"title": "Mark degraded rows so cost comparisons stay honest",
"summary": "A column on the row, so a cheap week and a degraded week are never averaged together by accident.",
"close_reason": "Column live; backfilled for the test window.",
"deps": [
"dotfiles-vx03",
"dotfiles-t0mz"
],
"requests": 83,
"tokens": 91000,
"dollars": 9.6
},
{
"id": "dotfiles-g7vu",
"type": "task",
"status": "closed",
"priority": 1,
"created": "2026-08-15",
"closed": "2026-08-17",
"closed_by": "the front-desk seat",
"title": "Runbook: what a human does when brownout fires",
"summary": "Short, and written for the version of me who is not awake.",
"close_reason": "Passed the cold read after two edits.",
"deps": [
"dotfiles-h5vd",
"dotfiles-b5jt"
],
"requests": 71,
"tokens": 66000,
"dollars": 6.3
},
{
"id": "dotfiles-f0ai",
"type": "decision",
"status": "closed",
"priority": 1,
"created": "2026-08-16",
"closed": "2026-08-16",
"closed_by": "the front-desk seat",
"title": "Ship without the queue-and-resume half",
"summary": "Queueing stopped work is a second feature. Degrade and stop are enough to be useful now.",
"close_reason": "Scoped out; filed as its own epic.",
"deps": [
"dotfiles-n83q",
"dotfiles-b5jt"
],
"requests": 27,
"tokens": 24000,
"dollars": 2.85
},
{
"id": "dotfiles-s6ow",
"type": "task",
"status": "open",
"priority": 3,
"created": "2026-08-16",
"title": "Retire the retry loop this replaced",
"summary": "The old client-side retry is dead code now, and dead code in a failure path is worse than most dead code.",
"deps": [
"dotfiles-b5jt"
],
"requests": 39,
"tokens": 34000,
"dollars": 3.1
},
{
"id": "dotfiles-lq5v",
"type": "task",
"status": "in_progress",
"priority": 2,
"created": "2026-08-17",
"title": "Write up what the failover cost",
"summary": "The draft this dataset is for, which is also the cheapest way I have found to check whether I understood my own work.",
"deps": [
"dotfiles-f0ai",
"dotfiles-x4rp"
],
"requests": 118,
"tokens": 129000,
"dollars": 14.3
},
{
"id": "dotfiles-brnt.0",
"type": "task",
"status": "closed",
"priority": 2,
"created": "2026-07-28",
"closed": "2026-08-18",
"closed_by": "the seat that tends the fleet's own tooling",
"title": "Coordination: the orchestrator sessions that spanned beads",
"summary": "Dispatch, merges, and the sessions that touched several beads at once. Charged here on purpose rather than smeared across whichever bead was nearby.",
"close_reason": "Closed with the epic; the honest overhead line.",
"deps": [],
"requests": 287,
"tokens": 312000,
"dollars": 38.9
}
];
export const seats = [
{
"id": "tooling",
"label": "fleet tooling",
"name": "the seat that tends the fleet's own tooling",
"note": "The harness working on the harness: hooks, launchers, schedulers. The fleet's largest appetite is itself.",
"fleet": {
"tokens": 28400000,
"requests": 34100,
"dollars": 2940.0
},
"epic": {
"tokens": 832000,
"requests": 773,
"dollars": 101.2
}
},
{
"id": "infra",
"label": "infra ops",
"name": "the infrastructure-operations seat",
"note": "Machines, routes, deploys. It owns the gateway, so it owned most of this.",
"fleet": {
"tokens": 19600000,
"requests": 22800,
"dollars": 2180.0
},
"epic": {
"tokens": 1519000,
"requests": 1358,
"dollars": 190.15
}
},
{
"id": "research",
"label": "research desk",
"name": "my research desk",
"note": "Reads and benchmarks. Two beads here decided what the other twenty-three were building on.",
"fleet": {
"tokens": 9850000,
"requests": 11900,
"dollars": 965.0
},
"epic": {
"tokens": 660000,
"requests": 580,
"dollars": 80.2
}
},
{
"id": "drain",
"label": "overnight",
"name": "the overnight-drain seat",
"note": "Claims approved work while nobody is watching. It wrote the tests and the fault harness.",
"fleet": {
"tokens": 7420000,
"requests": 8600,
"dollars": 742.0
},
"epic": {
"tokens": 585000,
"requests": 564,
"dollars": 57.35
}
},
{
"id": "desk",
"label": "front desk",
"name": "the front-desk seat",
"note": "The single ingress. Its whole contribution here was two decisions and a runbook.",
"fleet": {
"tokens": 6340000,
"requests": 7900,
"dollars": 733.0
},
"epic": {
"tokens": 123000,
"requests": 136,
"dollars": 13.35
}
},
{
"id": "critic",
"label": "review",
"name": "the adversarial-review seat",
"note": "Read-only. It cannot write code, which is the point.",
"fleet": {
"tokens": 4180000,
"requests": 5300,
"dollars": 468.0
},
"epic": {
"tokens": 302000,
"requests": 331,
"dollars": 35.4
}
},
{
"id": "ambassador",
"label": "ambassador",
"name": "this ambassador seat",
"note": "The only one aimed outward. It writes the posts about what the others did.",
"fleet": {
"tokens": 2910000,
"requests": 3400,
"dollars": 301.0
},
"epic": {
"tokens": 129000,
"requests": 118,
"dollars": 14.3
}
},
{
"id": "tail",
"label": "everything else",
"name": "the long tail — six smaller seats",
"note": "Six named seats with their own jobs, none of which this epic ever touched.",
"fleet": {
"tokens": 9300000,
"requests": 12000,
"dollars": 571.0
},
"epic": {
"tokens": 0,
"requests": 0,
"dollars": 0
}
}
];
export const taps = [
{
"id": "primary",
"label": "primary plan",
"note": "The subscription most seats launch against. It ran out on Thursday.",
"window": {
"dollars": 1658.66
},
"segments": {
"seat": [
{
"id": "tooling",
"label": "fleet tooling",
"dollars": 596.17
},
{
"id": "infra",
"label": "infra ops",
"dollars": 388.94
},
{
"id": "research",
"label": "research desk",
"dollars": 246.83
},
{
"id": "drain",
"label": "overnight",
"dollars": 177.68
},
{
"id": "desk",
"label": "front desk",
"dollars": 109.14
},
{
"id": "critic",
"label": "review",
"dollars": 87.64
},
{
"id": "ambassador",
"label": "ambassador",
"dollars": 45.02
},
{
"id": "none",
"label": "unattributed",
"dollars": 7.24
}
],
"bead": [
{
"id": "dotfiles-p2xk",
"label": "harness: hook refactor",
"dollars": 355.37
},
{
"id": "dotfiles-v8qd",
"label": "harness: scheduler rewrite",
"dollars": 245.82
},
{
"id": "dotfiles-t0mz",
"label": "the cache bug",
"dollars": 190.15
},
{
"id": "dotfiles-pd48",
"label": "the policy matrix tests",
"dollars": 135.07
},
{
"id": "dotfiles-lq5v",
"label": "the write-up",
"dollars": 57.49
},
{
"id": "dotfiles-k9sf",
"label": "the flapping probe",
"dollars": 49.85
},
{
"id": "dotfiles-m2ed",
"label": "the degraded-row column",
"dollars": 38.59
},
{
"id": "dotfiles-y2hb",
"label": "the unearned success",
"dollars": 37.79
},
{
"id": "dotfiles-b5jt",
"label": "the merge gate",
"dollars": 35.98
},
{
"id": "dotfiles-g7vu",
"label": "the runbook",
"dollars": 25.33
},
{
"id": "none",
"label": "no bead named",
"dollars": 487.22
}
]
}
},
{
"id": "secondary",
"label": "secondary plan",
"note": "The overflow. Quiet until the primary went dry, then it carried the week.",
"window": {
"dollars": 516.36
},
"segments": {
"seat": [
{
"id": "infra",
"label": "infra ops",
"dollars": 236.78
},
{
"id": "tooling",
"label": "fleet tooling",
"dollars": 137.28
},
{
"id": "drain",
"label": "overnight",
"dollars": 78.79
},
{
"id": "research",
"label": "research desk",
"dollars": 37.79
},
{
"id": "critic",
"label": "review",
"dollars": 20.9
},
{
"id": "none",
"label": "unattributed",
"dollars": 4.82
}
],
"bead": [
{
"id": "dotfiles-t0mz",
"label": "the cache bug",
"dollars": 167.23
},
{
"id": "dotfiles-p2xk",
"label": "harness: hook refactor",
"dollars": 89.85
},
{
"id": "dotfiles-pd48",
"label": "the policy matrix tests",
"dollars": 73.16
},
{
"id": "dotfiles-lq5v",
"label": "the write-up",
"dollars": 24.52
},
{
"id": "none",
"label": "no bead named",
"dollars": 161.6
}
]
}
},
{
"id": "work",
"label": "work account",
"note": "Employer-funded, and only the seats doing employer work may touch it.",
"window": {
"dollars": 791.14
},
"segments": {
"seat": [
{
"id": "infra",
"label": "infra ops",
"dollars": 291.05
},
{
"id": "tooling",
"label": "fleet tooling",
"dollars": 222.31
},
{
"id": "desk",
"label": "front desk",
"dollars": 133.06
},
{
"id": "critic",
"label": "review",
"dollars": 86.43
},
{
"id": "research",
"label": "research desk",
"dollars": 50.65
},
{
"id": "none",
"label": "unattributed",
"dollars": 7.64
}
],
"bead": [
{
"id": "job-r4c1",
"label": "day job: pipeline triage",
"dollars": 258.08
},
{
"id": "job-m9dt",
"label": "day job: dashboard fix",
"dollars": 195.17
},
{
"id": "job-q2wl",
"label": "day job: data backfill",
"dollars": 121.0
},
{
"id": "none",
"label": "no bead named",
"dollars": 216.89
}
]
}
}
];
In late July I want to experiment with some open models on a spare Mac Studio in my house (a symptom of the times, I know), to see what they could achieve alongside my foundation model usage.
By the end, I knew what the experiment had burned in tokens. After all, every model call from every agent I run crosses a single [agentgateway](https://agentgateway.dev) process on its way to the API, so the token count was never in doubt. But what the count couldn't tell me was what the tokens had *accomplished*.
- Which task was the expensive one?
- Did that bug cost more than the feature that shipped it?
Tokens are a fact about my usage; what they bought is a fact about my work, and the two live in different systems on my machines. So I set out to join them in one view.
## The token count answers a question I stopped needing to ask
The cost conversation still runs on volume. The vernacular has split into tokenmaxxers, and even tokenminmaxers now, arguing about which direction the token spend should go: up or down.
Luis Morales, Head of AI at Super.com, gave me a shape for why that stalls. In ["We Stopped Counting Tokens"](https://www.linkedin.com/pulse/we-stopped-counting-tokens-luis-morales-swfre/) he lays measurement out as a ladder, utilization then output then outcome, and puts an expiry date on each rung:
> Utilization metrics are the first rung of the ladder. You need them early. Then you need to outgrow them fast.
His test for an expired metric landed hard here: "A question that always returns the same answer has retired itself." Am I using tokens? Yes. Tomorrow: yes. I kept asking it because it was the question my tooling could answer.
## The spenders and the work already had names
I borrowed both halves from Steve Yegge, who has been writing about what it is like to run a crew of agents day to day. In ["Model Welfare for Agentic Engineers"](https://yegge.ai/essays/model-welfare/) he portrays what I do, as well:
> Sessions are days, and seats are people.
A seat, on my machines, is a session I keep handing the same name and the same memory file, pointed at the same kind of work.
The work has names too. Every unit of it is a bead, a small linkable record in an agent-native issue tracker. The concept is Yegge's ([Beads](https://github.com/gastownhall/beads)); the engine I run is [beads_rust](https://github.com/Dicklesworthstone/beads_rust), a fast Rust port from Jeffrey Emmanuel. A seat claims a bead, works it, closes it with a reason, and the beads link into a graph that remembers what the fleet did.
So I already had two graphs, one of identity and one of work, with the gateway's receipts sitting between them.
## Cost breakdown for an actual epic in beads
Here's that actual experiment, broken down by bead and cost. The goal was to find out whether a local model can hold a real coding session, write the rules for what degrades and what stops, wire the second backend, and learn what a failover does to the bill:
Each node is one task with its own record:
- who claimed it
- why it closed
- what it depended on
The epic is the parent; research, specs, decisions, features, bugs, and reviews hang under it, each carrying its own work and its own review, the way any epic you have ever scoped does.
## The gateway stamps every request with a seat and a task
Each seat's launcher stamps two identity headers on its traffic, and the gateway lifts them into columns on its request log with one line of CEL apiece:
```yaml
standardAttributes:
user: 'default(request.headers["x-session-identity"], "")'
group: 'default(request.headers["x-billing-origin"], "")'
```
CEL is Common Expression Language, a small rule language proxies use for jobs exactly this size; the line reads as *take this header, or fall back to an empty string*. Anything that cannot identify itself lands in one bucket of unknown spenders. When I started, that bucket collected 1000+ requests a week. Last week it collected only 15 unidentified sessions. Effectively all the inference is identified and labeled now, letting me do some pretty interesting observations on what the seats accomplish day to day.
The second header names the task, which is a node in a graph of tasks. The value of pivoting on this graph cannot be understated: the entire graph of planned and finished work can be pinned to each inference call, at whatever stage is the focus of the task. This allows me to not only understand what tasks cost, but also which specific parts of the task were expensive. Then you start to learn things, like failure modes you'd otherwise miss, or the model doing something that should be hardened in a script.
For each subagent, the Claude process has the ID of the bead it is working in its exports, so the header rides every call that session makes, and the gateway writes it onto the row next to the dollars it derives from token usage. On a subscription those dollars are list-rate equivalents, which is great, it shows how much value I get from my flat subscription each week (I wouldn't be able to afford this on API costs, I'll just say that).
## Each bead comes back wearing its cost
The same graph, with the dollars written on. Reading it takes no spreadsheet:
- Each task's number is its own spend, the calls made under that task and nothing else.
- The epic's figure is the plain total of the tasks underneath it.
- Coordination, the sessions that dispatch and merge everything else, carries its own line instead of being smeared across the work it touched.
The most expensive task in the experiment wrote no code at all: the research that benchmarked local models against real bugs from my own backlog, at $61.80, and it decided what everything else was built on.
## Seat totals show where the three weeks went
Zoom out and the ranking is predictable, at least for an AI explorer who is constantly refining his own harness. The seat that tends the fleet's tooling leads, infrastructure follows, and the whole fallback epic is a thin sliver inside each box. My invoice already knew this much. It is where I used to stop, because it was as far as my numbers went.
## Filter to one epic and the leaderboard flips
Filter the same seats down to the fallback work alone and the ranking inverts:
- The infrastructure seat jumps to 1st, which is what owning the gateway route costs.
- The fleet's biggest spender drops to 2nd, and more than a third of what it spent here was coordination rather than code.
My front desk turns out to be the epic's cheapest line: two decisions and a runbook on the process. The read-only critic bought the merge gate.
This is still an early experiment, and I only run one machine on this practice. But it opens an opportunity for you, as the harness operator, to ask higher-order questions about the efficiency of the system, because the entire thing is a feedback loop. Any session can connect work to tasks to inference to the seat responsible for owning it, and reason and act on it. Some of these questions for me have been:
- Is my research desk cheap because it is efficient, or because I under-use it?
- Why is the seat that makes my decisions the cheapest line on the graph?
- What opportunity am I missing?
- What is taking up most of my time and tokens?
- Of what I completed, what had the biggest impact?
I can picture these numbers across a whole engineering org, but I haven't gotten there yet.
## The taps are the layer I actually pay
Steve Yegge named this layer too. His fleet drinks from what he calls a [token tap](https://yegge.ai/essays/the-shape-of-things-to-come/), a pool of flat-rate subscription accounts his agents rotate through instead of metered API billing, and he runs a dozen of them.
I only have 3. (😇)
Since all of the inference is labelled even by tap in the gateway, I can segment any given week to find how much of each tap was consumed by which seat:
I can split that same tap across beads as well to find which bead epics took up my time and precious tokens that week:
## Put a cost on a unit of your own work
If you're a software leader right now trying to map inference to outcomes, you really need a context layer to capture this same level of insight but across all of your engineers.
What surprised me is how loose the unit of work can be when you attribute cost to a bead. Our industry tends to ask for *cost per PR*, but my agents spend most of their time doing work that doesn't end up as a PR. That didn't stop me from trying to valuate it, however.
I found that attributing the cost to a bead, a unit of work that can be defined as truly *anything*, is the right tempo of scale to match the inference pattern used by models.
And even better: a PR can neatly contain a record of all the beads that produced it, thus earning the *cost per PR* by adding the cumulative work inside it. Because you're tracking at the inference level, you're getting a much more accurate sense of cost anyways, compared to the PR which is only going to capture costs associated with tasks around code gen.
I had my infra agent cut up some examples for you to study below, if you want to try running this experiment yourself. Be sure to try out [agentgateway](https://agentgateway.dev) and [beads_rust](https://github.com/Dicklesworthstone/beads_rust) regardless. I also wrote a tutorial on [how to stand a gateway like this up](https://www.andrewzigler.com/feed/if-you-give-a-goose-an-mcp-server) that's a good place to start after learning the tools.
gl;hf! 😎
---
## Appendix: the pieces worth copying
**1: lift two headers into columns.** `standardAttributes` is read at startup only, so this is a restart, not a hot reload. Validate first, and watch the validator reject a deliberately broken control before you trust it.
```yaml
config:
database:
url: "sqlite:///var/lib/agentgateway/requests.db?mode=rwc"
standardAttributes:
user: 'default(request.headers["x-session-identity"], "")'
group: 'default(request.headers["x-billing-origin"], "")'
```
```sh
agentgateway --validate-only -f config.yaml # non-zero exit names the bad field
```
**2: the sanitizer that makes the unknown-spender bucket structural.** The filter cannot emit `?`, which frees `?` to mean "nothing was derived here" and nothing else.
```sh
sanitize() { printf '%s' "${1:-}" | tr -s '[:space:]' '-' | tr -cd '[:alnum:]._-'; }
seat=$(sanitize "${MY_SEAT_NAME:-}")
export SEAT_ADDRESS="${seat:-?}" # a real name can never contain '?'
```
**3: group by seat, last 7 days.** Read a snapshot, never the file the gateway is writing.
```sh
sqlite3 requests.db "VACUUM INTO '/tmp/snap.db'"
```
```sql
-- datetime() is load-bearing: started_at is ISO8601 with a 'T', and a bare
-- string compare matches every row with today's date without erroring.
SELECT agentgateway_user AS seat,
count(*) AS requests,
sum(coalesce(total_tokens, 0)) AS tokens
FROM request_logs
WHERE datetime(started_at) >= datetime('now', '-7 days')
GROUP BY seat
ORDER BY tokens DESC;
```
**4: the bead join.** The identity schema is closed at two columns, so the per-task header rides the access log's attribute JSON, which is the path that hot-reloads. The sentinel lives in the config, not the client: a launch that never heard of beads still lands as `none`, honestly.
```yaml
frontendPolicies:
accessLog:
database:
add:
bead_id: 'default(request.headers["x-bead"], "none")'
```
```sql
-- per-task spend, sentinel excluded; keep the extraction parenthesized
SELECT (attributes_json ->> '$.bead_id') AS bead,
count(*) AS requests,
round(sum(cost), 2) AS dollars
FROM request_logs
WHERE (attributes_json ->> '$.bead_id') IS NOT NULL
AND (attributes_json ->> '$.bead_id') != 'none'
GROUP BY bead
ORDER BY dollars DESC;
```
---
*Andrew Zigler is a 2026 AAIF Ambassador. Find the cohort at [aaif.io/ambassadors](https://aaif.io/ambassadors). I wrote up [how to stand a gateway like this up](https://www.andrewzigler.com/feed/if-you-give-a-goose-an-mcp-server) in July; this piece is what its receipts taught me to ask for.*
# If you give a Goose an MCP server
Source: https://www.andrewzigler.com/feed/if-you-give-a-goose-an-mcp-server
Published: 2026-07-08
*A hands-on build: register a local model, federate your MCP servers into one endpoint, scope that endpoint down to the six tools the job needs, and watch every model and tool call land in one log, tagged with the key that made it. Built on agentgateway.*
If you give a Goose an MCP server, it gets every tool the server ships.
You handed it a filesystem server because it needed to read one file. It got the read tool, and the write tool, and the edit tool, and 11 more, because `tools/list` came back full and nobody reads a tool manifest the way they read a diff. The filesystem server exposes 14 tools. The Goose now has all 14, because the server decides what the Goose can do, and that server was written to do everything.
This is a build for taking that decision back, by hand, once, so the tool layer stops being a black box you accept on faith. You put a gateway in the one place every call has to cross. You register your model there, so model calls ride the same layer as tool calls. You federate your MCP servers into a single endpoint and grant one agent exactly six tools on it. Then you watch the whole session, model calls and tool calls alike, land in one log, each row tagged with the key that made it. By the end the Goose holds six tools it can't step outside of, and a dashboard shows you every one it reached for.
The gateway is [agentgateway](https://github.com/agentgateway/agentgateway), a single Rust binary and an [AAIF](https://aaif.io) project. It bills itself as a connectivity solution for agents: one proxy in front of model traffic, MCP traffic, and ordinary HTTP, applying the same auth, policy, and logging to all three. That "all three" is what makes this build work. The model and the tools go through one door, so one log holds the whole session. I'm running it in standalone mode — a single binary and a YAML file, with an admin UI at `:15000/ui` that hot-reloads on save — in front of [Goose](https://goose-docs.ai/) talking to a local model. The six-tool set I grant the Goose has a name (that I made up): **tiny-tools**.
One important thing to note: the agent that *builds* this needs a shell and room to install things, so the builder is you, or an agent that still has the run of the house. The Goose we keep narrowing to six tools is the subject on the workbench. You hold the wrench.
Everything here is one config file. The slices nest like this, top-level keys first:
```yaml
config:
database:
url: "sqlite:///path/to/requests.db?mode=rwc" # the request-log DB (Step 5)
standardAttributes:
user: 'apiKey.name' # stamps every logged row with the calling key's name
frontendPolicies:
accessLog:
database:
add: { prompt: 'llm.prompt', completion: 'llm.completion' } # capture prompts + completions (Step 5)
llm: # Step 1 -- the model listener
port: 15003
providers: [ ... ]
models: [ ... ]
policies:
apiKey: { ... } # the goose key, so model calls are keyed and attributed
mcp: # Step 2 -- the federated tool listener
port: 15001
targets: [ ... ]
policies:
apiKey: { ... } # Step 3 -- the per-agent keys (goose, cc)
mcpAuthorization: { ... } # Step 3 -- match the key name to scope its tools
```
**Before you start.** [Install agentgateway](https://agentgateway.dev/docs/) and get it running. Save your config as `config.yaml` and launch with `agentgateway -f config.yaml`; the admin UI at `:15000/ui` hot-reloads on every save, and `agentgateway -f config.yaml --validate-only` checks a config before you run it. Have [Ollama](https://ollama.com/) serving a tool-capable model (`ollama pull qwen3:8b`), install [Goose](https://goose-docs.ai/), and keep `npx` (for the filesystem server) and `uvx` (for the fetch server) within reach.
## Step 1: Put your model behind the gateway
Start with the model. In my case, I'll use Ollama, just because I'm used to it, but you can use anything that serves a model. Routing it through the gateway is the piece most local setups skip, and it decides whether you can watch the whole session later. Route the model through the gateway and its calls land in the same request log as the tool calls, tagged with the same key. Leave it pointed at Ollama directly and you get a curated tool layer with a blind spot where the model should be.
An agentgateway `llm` block is a provider plus a list of models. The provider names your backend once; each model references it. The listener also carries the `goose` key, so a model call arrives identified:
```yaml
llm:
port: 15003
providers:
- name: local
provider: ollama
params:
baseUrl: http://localhost:11434/v1
models:
- name: qwen3:8b
provider:
reference: local
policies:
apiKey:
mode: strict
keys:
- key: sk-... # the same goose key you'll reuse in Step 3
metadata: { name: goose }
```
The gateway now exposes an OpenAI-compatible API on `:15003`, and every model call through it is one row in the request log, stamped `goose` by `standardAttributes.user`. Point Goose at the gateway with four environment variables:
```sh
export GOOSE_PROVIDER=openai
export OPENAI_HOST=http://localhost:15003 # bare host:port; Goose appends the OpenAI path
export OPENAI_API_KEY=sk-... # the goose key -- our LLM listener is keyed
export GOOSE_MODEL=qwen3:8b
```
Agentgateway's own Goose guide uses a placeholder key here, because its example listener takes any key. Ours doesn't. The `goose` key is what stamps every model row with the same identity the tool calls carry, so it has to be the real one.

> **Agent:** in agentgateway's `llm` config, register an Ollama provider pointed at my local Ollama (`baseUrl` `http://localhost:11434/v1`), add my model referencing it, and attach an `apiKey` policy carrying the `goose` key so model calls are authenticated and attributed. Set `standardAttributes.user: 'apiKey.name'` in the top-level `config` block. Then point Goose at the gateway with `GOOSE_PROVIDER=openai`, `OPENAI_HOST=http://localhost:15003`, `OPENAI_API_KEY=`, `GOOSE_MODEL=qwen3:8b`. Read agentgateway's [Ollama provider docs](https://agentgateway.dev/docs/standalone/latest/llm/providers/ollama/) and its [Goose integration guide](https://agentgateway.dev/docs/standalone/latest/integrations/web-uis/goose/).
## Step 2: Federate your MCP servers into one endpoint
Now the model has somewhere to reach. Give it tools. The interesting part is *how* you give them. You federate several MCP servers behind the gateway and hand the agent a single endpoint, rather than wiring one connection per server into the client. The gateway multiplexes them. The agent connects to one place, and the servers behind it are the gateway's concern.
You define each server as a `target` under the `mcp` block. Two small servers here, a filesystem server scoped to one workspace directory and a fetch server:
```yaml
mcp:
port: 15001
targets:
- name: filesystem
stdio:
cmd: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"]
- name: fetch
stdio:
cmd: uvx
args: ["mcp-server-fetch"]
```
Between them, that's 15 tools behind one endpoint. The gateway namespaces each tool by its target, so the filesystem server's `read_text_file` shows up as `filesystem_read_text_file`. Open the Servers view and both targets register and report ready. The Goose connects to this one endpoint, never to either server directly.

> **Agent:** federate two `stdio` MCP targets under one `mcp` endpoint: `@modelcontextprotocol/server-filesystem` scoped to a single workspace directory (target name `filesystem`) and `mcp-server-fetch` (target name `fetch`). Confirm both report ready and that the combined `tools/list` shows all 15 tools, target-prefixed. Read agentgateway's [MCP multiplexing docs](https://agentgateway.dev/docs/standalone/latest/mcp/connect/virtual/); a target name can't contain underscores.
## Step 3: Scope the endpoint per agent
Give a Goose 15 tools and it will find a use for one you never meant it to have. So before the Goose ever connects, you decide what's on the endpoint for it, and you decide it per key. One virtual endpoint, and each API key gets its own view of it.
Let's scope the MCP tools into custom virtual MCP servers for two different agents: one on Goose, and one in Claude Code. We're picking the Goose's six by hand for now; Step 7 shows how to derive that set from real traffic instead.
```yaml
policies: # under the mcp block from Step 2
apiKey:
mode: strict
keys:
- key: sk-... # the Goose's key (the same one from Step 1)
metadata: { name: goose }
- key: sk-... # Claude Code's key
metadata: { name: cc }
```
Then we match on the key's name to scope which tools each one sees:
```yaml
mcpAuthorization:
rules:
- allow: apiKey.name == "cc"
# mcp.tool.name is the underlying tool name (read_text_file), not the
# filesystem_-prefixed name the client sees in tools/list
- allow: apiKey.name == "goose" &&
mcp.tool.name in ["read_text_file", "write_file", "edit_file",
"list_directory", "search_files", "fetch"]
```
The `cc` key (Claude Code) gets everything; the `goose` key gets six. Same endpoint, and the key decides the view. Write one `allow` rule and the endpoint flips to default-deny: anything not allowed is denied, and a tool the `goose` key doesn't allow drops out of `tools/list` entirely, so the Goose never discovers it exists. That scoped view is tiny-tools.

> **Agent:** on the federated MCP endpoint, add a `strict` API-key policy with keys named `goose` and `cc`, and an `mcpAuthorization` rule allowing the `goose` key exactly `read_text_file, write_file, edit_file, list_directory, search_files, fetch`. Confirm a session opened with the `goose` key discovers only those six. Read agentgateway's [MCP authorization docs](https://agentgateway.dev/docs/standalone/latest/mcp/mcp-authz/): one `allow` rule flips the endpoint to default-deny, and denied tools drop out of `tools/list`.
## Step 4: Point Goose at your virtualized MCP server
You built the scoped endpoint. Now point your Goose at it. Step 1 aimed Goose's model at the gateway; this aims its tools there too, at the tiny-tools view you just carved out.
Run `goose configure`, choose **Add Extension → Remote Extension (Streamable HTTP)**, and point it at the gateway's MCP endpoint, `http://localhost:15001/mcp`, with an `Authorization: Bearer ` header so it connects as the `goose` key. The Goose now reaches its tools through the gateway, and the gateway hands back exactly the six that tiny-tools allows.
By the way, Goose does a lot of what's in tiny-tools out of the box, through a built-in `developer` extension with its own shell and editor. Toggle it off for this run (`goose configure → Toggle Extensions`) so tiny-tools is the entire surface, which is the point when you're trying to see the tool layer plainly. In your own setup you'd keep it or drop it depending on whether you want those built-ins alongside your list.
Start a Goose session and its tool list is exactly tiny-tools: the six you granted the `goose` key, and nothing underneath.
> **Agent:** in Goose, add a remote streamable-HTTP MCP extension pointed at `http://localhost:15001/mcp` with an `Authorization: Bearer ` header, and disable the built-in `developer` extension. Confirm the Goose session's tools are exactly the six from tiny-tools. Read Goose's [extensions guide](https://goose-docs.ai/docs/getting-started/using-extensions/).
## Step 5: Watch the whole session in one place
Every call the Goose makes now crosses the gateway, model and tools alike. So watch them. This is the payoff of routing the model through the gateway back in Step 1: one gateway holds the whole session, in one place you can query.
That place is the request-log database, the same store behind the admin UI's Logs tab. Turn it on in the top-level `config` block and tag every row with the calling key:
```yaml
config:
database:
url: "sqlite:///path/to/requests.db?mode=rwc" # the request-log DB
standardAttributes:
user: 'apiKey.name' # tag every row with the key's name
```
Every call the gateway proxies writes a row there. A tool call at the MCP listener and a model call at the LLM listener both land in the same table, each row carrying its protocol (`mcp` or `llm`) and, because of `standardAttributes.user`, the name of the key that made it. There's also a text access log you can tail to watch calls fly by live; the database is the durable, queryable version, and it's what the skills in Step 7 read.
I gave the Goose a task that read a file, wrote a new one, and fetched a page. Pulling the fields that matter from its rows, the whole session comes back under one identity, tools and model together:
```
protocol=mcp read_text_file user=goose status=200 16ms
protocol=mcp write_file user=goose status=200 8ms
protocol=mcp fetch user=goose status=200 782ms
protocol=llm qwen3:8b user=goose status=200 1533ms
```
One key made every call, so one identity tags every row. That is why the model went behind the gateway in Step 1. (The text access log labels that identity `agent` rather than `user`; both read from `apiKey.name`, so it's one value under two names.)
Turn on payload capture and the log holds not just that the Goose called the model but what it said:
```yaml
frontendPolicies:
accessLog:
database:
add: { prompt: 'llm.prompt', completion: 'llm.completion' }
```
It's worth having on. Those rows now carry full prompts and completions, so treat the database as sensitive.

> **Agent:** set `config.database.url` and `standardAttributes.user: 'apiKey.name'` so the request-log DB records every call with identity. Run a task through Goose that reads a known file, writes a new one, and fetches a URL. Confirm the DB (and the admin UI's Logs tab) shows the tool rows (`protocol=mcp`) and the model row (`protocol=llm`) all under `user=goose`. Turn on payload capture (`frontendPolicies.accessLog.database`) to also record the prompt and completion.
## Step 6: Prove the tools you cut are gone
An agent never reaches for a tool it can't see — that's the whole point of scoping, and it's also why you can't prove the wall holds just by watching the Goose work. So reach for the cut tool yourself. `move_file` is one of the filesystem server's original tools, from before you virtualized it, and the `goose` key never got it. Issue a `tools/call` for it straight to the gateway's MCP endpoint with the `goose` key, the same request the Goose's own client would send. (MCP over HTTP opens with an `initialize` handshake that hands back a session id; reuse it here.)
```sh
curl -sS http://localhost:15001/mcp \
-H "Authorization: Bearer $GOOSE_KEY" -H "Mcp-Session-Id: $SESSION" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"filesystem_move_file",
"arguments":{"source":"a.txt","destination":"b.txt"}}}'
```
The gateway answers `HTTP 400` with a JSON-RPC error:
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Unknown tool: filesystem_move_file"}}
```
That error turns on one word: *unknown*. A tool the gateway called *forbidden* would still be in the manifest, a name the Goose could see, reach for, and be told no. This one is *unknown*. It never entered `tools/list`, so there's no name to reach for, and as far as the `goose` key's view of the endpoint goes, `move_file` was never there. The attempt still lands in the log, so you can watch it happen and watch it go nowhere.
> **Agent:** call `tools/call` for `filesystem_move_file` against the endpoint with the `goose` key, and confirm the gateway returns an "Unknown tool" error while still recording the attempt. This verifies the allowlist filters at discovery, before the call is even attempted.
## Step 7: Run the loop with one skill
You built tiny-tools by guessing which six the job needs. The skill loop is how you stop guessing. Point the Goose at a full MCP server — a new one you're onboarding, before you've scoped it, on a key that can still see everything — and let it work through a real task. The gateway records all of it. Then you read the record and cut: keep the tools the Goose actually reached for, notice which ones carried real weight, and virtualize the rest away. You whittle a fat server down to a tiny-tools that fits the workflow, from what happened instead of from a guess.
I packaged four read-only components as one [agentgateway skill](https://github.com/azigler/aaif/tree/main/.claude/skills/agentgateway), a Claude Code Agent-Skill you drop in your `.claude/skills/`:
- **audit** groups every call by identity, tool, and outcome, so you can see which of a server's tools the Goose actually used, and how often.
- **cost** reads the token counts, so you can see which tools carried the weight. The dollar column stays empty on a local model, but the tokens are the number you tune.
- **trace** walks a single call, or a session, end to end when one of them looked wrong.
- **harden** reads what the Goose actually used and drafts the tighter allowlist that would have permitted exactly that, then hands it to you. It proposes; it never applies. A rule that could lock out real traffic waits for a human.
They read the request-log database over a consistent snapshot, never the live file, and they share one [request-log cookbook](https://github.com/azigler/aaif/blob/main/refs/gateway-request-log-cookbook.md), the verified query foundation underneath all four. Continuous, standing alerting isn't in here on purpose, because a skill can't hold a live watch. Run audit and cost on a cadence and read the deltas.
The morning after a real run, the loop composes: **audit** shows the Goose reached for a handful of the server's tools and never touched the rest; **cost** shows which of them carried the token weight; **trace** explains the one slow call; and **harden** hands you the tighter allowlist — the tiny-tools for this workflow — built from what actually happened, for you to validate and apply. Read-only the whole way, human-gated at the one step that changes anything.
> **Agent:** install the [agentgateway skill](https://github.com/azigler/aaif/tree/main/.claude/skills/agentgateway) and its request-log cookbook. After a real Goose session against a full server, run **audit** to see what the Goose actually used, then **harden** to draft the whittled-down allowlist for my review. It proposes policy; it never applies it.
The black box was a list and a rule the whole time. Now it's yours.
---
*Andrew Zigler is a 2026 AAIF Ambassador. Find the cohort at [aaif.io/ambassadors](https://aaif.io/ambassadors).*
# Depths (Ludum Dare 57)
Source: https://www.andrewzigler.com/feed/crossed-wires-ludum-dare-59
Published: 2026-04-19
A small-town switchboard operator's shift. Every call is tangled. You sort.
Two callers arrive on the same phone line. You assign each of their utterances back to the right conversation. Early calls are easy — the goat lady does not sound like the shipping clerk. Later calls have static eating the words on the wire, and you sort by how each caller talks — all-caps outbursts versus two-word dispatches, grief versus directive. Voice recognition as puzzle.
Built solo over the Ludum Dare 59 jam weekend with a handcrafted Claude Code agentic harness and pair-coded with Claude Opus 4.7. The harness pulls in Impeccable design skills and a cross-engine game-dev agent team, alongside project-local skills for spec, impl, review, commit, manifesto and indie-game-taste.
# Genuary 2026
Source: https://www.andrewzigler.com/feed/genuary-2026
Published: 2026-01-31
[Genuary](https://genuary.art/) is an annual, month-long creative coding challenge where artists create generative art daily based on specific prompts. It's a celebration of algorithmic art, creative coding, and the vibrant community of generative artists worldwide.
# Building future AI news experiences with The Atlantic and Infactory
Source: https://www.andrewzigler.com/feed/infactory-the-atlantic-hackathon
Published: 2026-01-31
I placed 1st in the [Infactory](https://www.linkedin.com/company/infactory-ai/) x [The Atlantic](https://www.linkedin.com/company/the-atlantic/) AI hackathon ([Building future AI news experiences with The Atlantic and Infactory](https://luma.com/hh-future-ai-news-theatlantic-infactory)) by creating a virtual classroom platform that empowers teachers, challenges students, and fosters a deeper relationship with journalism using The Atlantic’s archives as a teaching resource. To create my demo, I orchestrated agents in Claude Code with beads.
# AdventJS 2025
Source: https://www.andrewzigler.com/feed/adventjs-2025
Published: 2025-12-25
A complete set of solutions for all 25 challenges in [AdventJS 2025](https://adventjs.dev/). Each challenge solved in JavaScript, TypeScript, and Python with comprehensive test suites and solution logs. Built a custom harness for automated fetching, testing, and submission with quality score tracking.
Completed all 25 challenges across all three languages (75 total solutions), achieving 5/5 quality scores on all submissions.
# Advent of Code 2025
Source: https://www.andrewzigler.com/feed/advent-of-code-2025
Published: 2025-12-12
Solutions for all 12 days of [Advent of Code 2025](https://adventofcode.com/2025). Each day includes TypeScript solutions with comprehensive test suites, edge case analysis, and detailed solution logs documenting the problem-solving process.
Built a custom harness for automated input fetching, answer submission, and star tracking with rate limiting and caching.
# Depths (Ludum Dare 57)
Source: https://www.andrewzigler.com/feed/depths-ludum-dare-57
Published: 2025-04-07
A game about navigating deep folder structures under time pressure. Players take on the role of an office worker who must find a specific file in a chaotic company server within a strict time limit. Created in 72 hours for [Ludum Dare 57](https://ldjam.com/events/ludum-dare/57).
# Club Work Out No Jogging
Source: https://www.andrewzigler.com/feed/club-work-out-no-jogging-sims-4-mod
Published: 2022-03-17
This Sims 4 mod fixed unintended behavior wherein Sims would go jogging while attending fitness-oriented club hangouts. This caused them to scatter all over the map and spend no time socializing with each other, which defeated the point of the feature. This mod adjusted the Sims autonomy to de-prioritize jogging. However, this mod is now deprecated because the original unintended behavior is no longer observed.
# More Club Members
Source: https://www.andrewzigler.com/feed/more-club-members-sims-4-mod
Published: 2022-03-17
This Sims 4 mod expanded the limit on club members in the game. By default, club members were arbitrarily capped at 8, but this mod lifted that cap to 20 or 50, depending on user preference. It also introduced more advanced club requirement filters for Sims, because at the time there were limited options. However, this mod is now deprecated. All functionality of this mod is provided by [Deaderpool's MC Command Center Clubs module](https://deaderpool-mccc.com/#/doc/mc_clubs).
# Review: Visme puts your visuals ahead of the curve
Source: https://www.andrewzigler.com/feed/review-visme-puts-your-visuals-ahead-of-the-curve
Published: 2019-12-29
I've been updating some of the images on my website, and conforming to a new image size for all of my social media shares. This turned out to be a pretty big project, since I needed to crop or rebuild over forty different blog post headers into the new format. I didn't want to do it with Photoshop again, so I joined [**Visme**](https://www.visme.co/) to make it easy! Visme is an easy-to-use online tool that you can use to create and organize all types of visual content. Visme has been around for over 5 years, so it has a lot to offer. Bloggers (like me!) are using it to create static graphics or presentations, but I promise that I'll try not to get too hammy with it!

What makes Visme easy is that you can start from professional templates or from a blank canvas. Visme also has millions of assets available right inside the web app (including vector icons), so (for me) this was a process that used to jump between Google Images and Photoshop but now resides solely in one spot: Visme. That simplification made working on my images easy and (for once) something I actually _enjoyed_ doing.

Visme also has an animation engine on top of the visuals. Virtually any object can be animated with a couple clicks. Visme has over a hundred high-quality and popular fonts that can be applied with many properties and effects. There are a variety of font styles to satisfy any project need and the entire process is simple. You can also apply these effects and properties granularly to each individual text character, allowing for truly fine-tuned control. And for me, that's what makes Visme truly shine!

Branding is simple using the tool, and one of the best features I found for that was the global color library, which allowed me to pick and save colors to reuse within my project. If you're using Visme to create literal presentations, then it's also good to know that you can upload your own audio MP3 files to your visual presentation. Visme also provides a large library of audio tracks that can be attached to one slide (for presentations) or used as background music for an entire project.
As I mentioned initially, I had to methodically go through each social media image on my website and recrop it to fit perfectly in my new dimensions. If it didn't fit, I then used Visme to rebuild or completely remake the image but with correct proportions. I was able to lean on Visme's pixel-precise tools to create images that popped more. The abundance of fonts let me mix up some of what I was doing before. I completed the project quicker than expected so I even made some extra images!
If you're interested in checking out the tool, know that [Visme is free for all users](https://www.visme.co/pricing/)! It takes only a few seconds to register and start creating your first project. Most of the features are free so it's easy to experiment with.
# JavaScript and Java Most in Demand Software Languages
Source: https://www.andrewzigler.com/feed/javascript-and-java-most-in-demand-software-languages
Published: 2019-11-22
Programming languages JavaScript and Java are the most sought-after skills for IT professionals, [according to a recent report from online academy Pluralsight](https://www.techrepublic.com/article/the-most-in-demand-technologies-for-it-professionals/). The report, called the Technology Index, breaks down the demand for a software developer’s proficiency in specific languages.
While both languages have been around for over two decades, JavaScript and Java continue to be the most popular in the majority of ranking sites.
[An analysis from DZone notes that learning Java](https://dzone.com/articles/top-programming-languages-rankings) as your first language is probably the best choice if you’re planning to have a career as a developer. Moreover, a 4-year analysis of job postings from job platform Indeed indicates that both Java and JavaScript remain the most in-demand programming skills. Similarly, there are almost 5 million students [learning JavaScript on Udemy](https://www.udemy.com/topic/javascript/), which is a clear indication of how widely used the program is across industries. With a wide user base and tons of libraries created over the past three decades, both languages remain the most reliable and widely used.
## Battle for the Web: Java vs. JavaScript
Many people still think the two are related but Java and JavaScript are two different languages altogether. Although they had a brief intersection in the early days of Netscape, the two top languages have diverted paths from thereon.
When JavaScript was first introduced in the 90s, it was packaged as a companion of the then-popular Java for web development. It was not until years later that [JavaScript was recognized as a programming language on its own](). Java, on the other hand, was a pioneer in web development as an object-oriented programming language.
While both languages have been in contention for the best language for web development for a long time now, JavaScript has emerged as the undisputed language for web applications. Around 94% of all webpages on the internet are made from JavaScript but it’s common to have them running Java on the back-end.
Five years ago, people would’ve made clear distinctions in terms of JavaScript being the language used for client-facing coding and Java for server-side coding. But the advent of Node.js made JavaScript a more efficient language to be used for back-end development. As a runtime environment, Node is a light, scalable, and cross-platform way to execute code. This marked the supremacy of JavaScript for the internet including enterprise web programs. With Vue.js and AngularJS frameworks for front-end and React for back-end, it has become the most versatile, fast, and scalable language for web platforms.
## Mobile development
Part of what kept Java afloat despite the rise of JS is its platform Java Virtual Machine. The "write once, run anywhere" principle of Java made it a versatile and universally compatible language as long as the device has a Java Runtime Environment.
This important feature made it the best language to use for developing mobile applications. In fact, it’s [the official language for Android development](https://www.androidauthority.com/develop-android-apps-languages-learn-391008/) and is supported by Android Studio. Around a fourth of the apps currently in the Google Play Store are made with Java and its frameworks.
In conclusion, both JavaScript and Java have stood the test of time. Their adaptability and capacity to integrate with new languages make them the most valuable skills for developers.
# Untitled Goose Game Review: Avian Agent of Chaos
Source: https://www.andrewzigler.com/feed/untitled-goose-game-review-avian-agent-of-chaos
Published: 2019-10-23
People online are losing their _minds_ over a casual simulation game where you take control of a mischievous goose. In its first two weeks, [_Untitled Goose Game_](https://goose.game/) sold over 100,000 copies worldwide. If we hadn't before, we've now _definitely_ crossed the Rubicon. With enough creativity, _anything_ can be a game.
One of the unique attributes of being a goose is that most people ignore you unless you're causing trouble. Waddling through a world of people absorbed in their own tasks and leisure, you can fly mostly under the radar. When you intrude on their livelihoods or otherwise make a mess, they'll chase and shoo you away. They'll shake their fist and rue the day, but their responses are fully non-violent and realistic. People care about and react to you in more or less the same way they would react to a real live goose in that situation: they'll flap their arms until they scare you off. One meek boy in particular will flee from you and hide! Contrast this with other games that use direct conflict to drive story and provide gameplay, the situations of _Untitled Goose Game_ become low-stakes and more like extended slapstick jokes. And it works. Magically. It would be absurd if it wasn't so realistic in the way the world reacts to you. It never tips into being unrealistic, which makes the experience that much more satisfying from the perspective of a goose. You're finally getting a piece of the pie from which all geese in this world freely eat: terrorizing humans for no discernible reason.
The game features an incredible and dynamic music score that reacts live to the choices you make. When you sneak up on someone or an objective, the pitter-patter of music will build and grow in turn. When you finally seize an opportunity or make some real trouble, the piano score will undergo a reactive crescendo that makes gameplay more like a performance you share with others. As a result, it's wickedly fun to play this game with an audience of friends. The musical choices of the game make revisiting the game enjoyable and always different.
The game is silly and creative, and I was laughing the whole way through. However, it's driven by the premise alone so once you complete all of the main tasks (and all of the bonus tasks) then there's very little reason to revisit it (other than to show your friends that you own _Untitled Goose Game_, of course). The pranks you can undertake are thrilling, but you'll hit a point where most everything has been done. The brief and endlessly funny interactions between the titular (untitular?) goose and the denizens of the village are charming but once you push all of everyone's buttons, there's very little else to try. The environment is peaceful and fun, which is why it's so fun to try and wreck the idyllic calmness of it all. The world is your canvas and the goose is your paint.
_Untitled Goose Game _drew similar attention as _[Goat Simulator](http://goat-simulator.com/) _on social media, both being sandbox-style games in which the player is invited to create chaos as a semi-domesticated animal. Similarly, the contents of both games have become memes in their own right because of the almost-absurd nature of the tasks involved. Indeed, _Untitled Goose Game_ invites memes with its relatively plain palette and simple graphics. The soft colors and flat greens make each moment a snapshot of a pastoral hamlet, almost lost in time with its narrow roads and winding aqueducts (on which you quickly sail away as a fast-swimming goose). The goose itself is white and simple, a most memetic representation of the bird there can be. [@Foone](https://twitter.com/Foone) has created a [meme generator](http://deathgenerator.com/#ugg) that lets you mock up a game image with your own _Untitled Goose Game_ tasks (and even cross them out), furthering the meme potential. The simplicity of its presentation makes it just as engaging to watch as it is to play. If you want to get your feathers on some mischief, you can buy _Untitled Goose Game_ now for PC, Mac, or the Nintendo Switch for only $20.
_**2021 update: **Untitled Goose Game now has a local two-player co-op mode, so you can play the same game with a friend and cause trouble as a duo! The game tasks are ultimately the same, but having an extra beak allows for extra mayhem._
# Relaxing with CouchDB
Source: https://www.andrewzigler.com/feed/relaxing-with-couchdb
Published: 2019-09-25
I'll admit it: databases scare me. I've always avoided databases in my projects because the idea of sorting and scheming all of my necessary data is an intimidating task. Up until now, if I needed to store data then I would save it to a local JSON file or put it in [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API). However, you can only do that for so long until you start hitting limits in your implementation. So this month I've been dedicating myself to learning databases. Ideally, I just want to think in terms of JSON, so it's no real surprise that my first major database exploration has been with [CouchDB](http://couchdb.apache.org/), which is a [NoSQL database](https://en.wikipedia.org/wiki/NoSQL) maintained by the [Apache Software Foundation](https://www.apache.org/). And I have to say, it's been a _[relaxing](http://docs.couchdb.org/en/stable/intro/why.html#relax)_ experience so far.
Before I discuss CouchDB and why it's so great, it's important to define the bigger picture. Proposed in 1970, [SQL](https://en.wikipedia.org/wiki/SQL) (Structured Query Language) is a programming language used to manage data in relational databases. Relational databases use relations (typically called tables) to store data, and they match that data by using common characteristics between them. SQL is used to create both data (in objects called tables) and the schema for that data, which describes fields as columns and a single record as a row.
Younger in comparison, [NoSQL](https://en.wikipedia.org/wiki/NoSQL) (Not Only SQL, or Not SQL) was coined in 1998 and defines a database that is self-describing, which means it does not require a schema or enforce relations between tables in all cases. All of its entries (typically called documents) are JSON, which are complete data object entities that applications can read and understand. NoSQL refers to high-performance, non-relational databases that utilize not just document-based schemas but a wide variety of data models for its entries. These databases are prized for their ease-of-use, scaling performance, resilience under pressure, and ample availability.
A CouchDB server instance has databases, which in turn store documents. Each document has a unique identifier within the database, and CouchDB provides a [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) HTTP API for manipulating the server and its contents. Documents are the bread and butter of CouchDB, consisting of any number of fields and attachments that can contain any amount of various value types. Documents also include metadata that’s maintained by the database system (like revision history). Documents form the heart of the database and can change dynamically beyond their original definition. CouchDB applies document updates without locking the entry, to improve performance for all connections. So if another client editing the same document saves their changes first, the remaining client gets an edit conflict error when saving because the document metadata (revision history) has changed. To resolve the update conflict, the latest document version should be opened, the edits reapplied, and the update reattempted. Manipulating entire documents (as opposed to their properties) either succeeds entirely or fails completely. CouchDB never contains partially saved or edited documents because the document commitment system is [ACID](https://en.wikipedia.org/wiki/ACID) (_**A**tomic_, _**C**onsistent_, _**I**solated_, and _**D**urable_).
CouchDB isn't the only NoSQL database out there. In fact, there are a lot! One of the most popular options is [MongoDB](https://www.mongodb.com/), which admittedly has more resources (i.e., a larger community with more tooling and marketing) than CouchDB. And MongoDB supports ad hoc querying, which makes the transition from SQL even easier if you're coming from that background. However, there is no _one_ single perfect database for all scenarios. Each database type offers certain features and prioritizes certain tenants of database design. This is best explained by the [CAP theorem](https://en.wikipedia.org/wiki/CAP_theorem), which says it's impossible for a distributed data store to simultaneously provide more than two out of the following three guarantees: _**C**onsistency_ (each client always has the same view of the data), _**A**vailability_ (all clients can always read and write), and _**P**artition tolerance_ (the system works well across physical network partitions).
CouchDB favors availability. For example, CouchDB supports full replication to mobile devices and desktop, so if you need a local database on your mobile application (or if your desktop users need to work offline and sync their work back to a server) then you should use CouchDB. CouchDB uses a replication model called [Eventual Consistency](http://docs.couchdb.org/en/stable/intro/consistency.html). In this system, clients can write data to one node of the database without waiting for other nodes to come into agreement. The system incrementally copies document changes between nodes, meaning everything will _eventually_ be in sync. Another important distinction is that CouchDB relies on [B-tree](https://en.wikipedia.org/wiki/B-tree) indexes. This means that whether you have 1 entry or 15 billion, the querying time will always remain below 10 milliseconds. This is huge for performance, making CouchDB low latency and very appealing.
MongoDB, on the other hand, favors consistency. It uses a single authoritative main server that accepts all writes and updates its replicas accordingly. Replicas can be read, but they may possibly not be completely up-to-date at that specific point in time. Consequently, there is no versioning like in CouchDB. In fact, updates can happen in-place, using an algorithm to pad frequently growing documents.
There are drivers and tooling available for CouchDB in several languages, including JavaScript. CouchDB itself is manipulated via a RESTful API, and it's surprisingly easy to get started that way:
```
const URL = "http://127.0.0.1:5984"
function createDB(dbName) {
var req = new XMLHttpRequest();
req.open("PUT", URL + "/" + dbName, true);
req.setRequestHeader("Content-type", "application/json");
req.send();
}
function updateDB(dbName, docName, data) {
var req = new XMLHttpRequest();
req.open("PUT", URL + '/' + dbName + '/' + docName, true);
req.setRequestHeader("Content-type", "application/json");
req.send(JSON.stringify(data));
}
createDB("baseball");
updateDB("baseball", "Rangers", {"pitcher":"Nolan Ryan"});
```
But building an interface like this from scratch is a huge hassle and I don't recommend it! Unless absolutely necessary, you should use a community-supported driver so your interface is well-developed and well-tested. When using CouchDB in JavaScript, I recommend [PouchDB](https://pouchdb.com/), which works both in the browser and in Node. By default, PouchDB ships with a [IndexedDB](http://www.w3.org/TR/IndexedDB/) adapter for the browser and a [LevelDB](https://github.com/google/leveldb) adapter in Node. By using the included adapters in both the browser and in Node, you can use PouchDB as an actual local database that's capable of syncing to a remote store, or you can simply use PouchDB as the interface for manipulating a remote store directly. PouchDB's API works the same in every environment, so you can spend less time worrying about browser/server differences and more time writing clean, consistent code. PouchDB can also be easily used with frameworks like React and Vue. You can either listen for events on a synced database and handle those events, or you can use a package like _[pouch-vue](https://github.com/MDSLKTR/pouch-vue)_ to handle the database directly in your components.
When I initially started digging into databases this month, I wasn't too sure how I wanted to practice my new skills. Coming from several website projects, I was more interested in working on a game or at least something more fun than functional, per se. The last game-related project that I worked on was my [Pinwheel MUD Engine](), and I still have much to do in terms of that project. However, the worldbuilding resources behind Pinwheel have been a personal project of mine for a long time, and many aspects are practically _begging_ to be plucked out and formed into their own independent projects. My Pinwheel MUD is envisioned as a multiplayer hybrid of both an artificial life simulation and a role-playing game. Early in development, one of the creatures I built using the engine was a small human-like frog that exhibited a wide array of characteristics. The concept immediately gripped me and was both intuitive and fun to develop, so I'm expanding on them in a new project called [Froggins]().
In this game concept, you're charged with caring for a nomadic tribe of frog-like creatures who migrate across a marsh and exhibit a wide array of social traits. Part of the game is simulation-based and involves nurturing your little pets as you grow the tribe. Another part of the game is RPG-based and focuses on their adventures in the marshlands. Both gameplay modes meld together to create a story unique to the player. You can trade them with other players to cultivate desired traits within your tribe, and once your creatures mature then you can imbue them with heroic abilities and send them out on quests. The game concept is still in early development and I hope to provide more details in a future blog post.
The concept is inspired by early artificial life simulations like [Creatures](https://en.wikipedia.org/wiki/Creatures_(video_game_series)) and browser-based virtual pet games like [Neopets](https://en.wikipedia.org/wiki/Neopets). Since you can train and battle your creatures, it's also a lot like [Pokémon](https://en.wikipedia.org/wiki/Pok%C3%A9mon). Virtual pets have always been fascinating to me because it's interesting how much we can grow to love a digital representation of a living thing. They're ultimately just 1s and 0s on a virtual machine somewhere but they can evoke emotions all the same.
CouchDB underpins the entire project, which persists on a server handling all game logic. Clients connect via the browser and relay user interactions back to the server, which then responds with the outcome. Building this server and it's corresponding client has been an eye-opening undertaking. One of the early obstacles involved creating a communication protocol that's shared between the WebSocket server and the Vue client. I standardized the format for sending and receiving these messages. And I call them _ribbits_, in celebration of the project's subject matter.
Simply speaking, CouchDB itself is an HTTP server that's capable of serving HTML directly to the browser. You can also attach binary files directly to a database, as well. As a result, you can use actually [serve any web application and its resources directly from a CouchDB instance](http://guide.couchdb.org/draft/standalone.html), removing the intermediary server component from any traditional client–server model. The loaded page can even talk directly to the database using the JavaScript served in the page itself, which completely eliminates the server layer in some implementations! CouchDB’s features are a foundation for building standalone web applications backed by a powerful database. Even CouchDB’s own built-in administrative interface, [Fauxton](https://couchdb.apache.org/fauxton-visual-guide/), is a fully functional database management application built using HTML, CSS, and JavaScript. CouchDB and web applications go hand in hand.
This type of application was once called a [CouchApp](https://github.com/couchapp/couchapp) before the functionality was integrated into CouchDB's [design documents](http://docs.couchdb.com/en/latest/ddocs/index.html), which store JavaScript code in the database. When that happened, the use of CouchDB as a combined standalone database and application server became no longer recommended by CouchDB's maintainers. There are significant limitations to a pure CouchDB web server application stack, including security, templating, and tooling. The developers of CouchDB recommend that we use CouchDB only as the database layer, in conjunction with a web application framework like Vue, Angular, or React.
Having dipped my toes into the waters of NoSQL and CouchDB, my fears and anxieties about databases have washed away! I've set up my own CouchDB instance on a server and configured it for use on my local machine. Now that I've learned the basics, I can't wait to integrate it into my pre-existing projects and any new ones!
# Using Phaser 3 with Nuxt
Source: https://www.andrewzigler.com/feed/using-phaser-3-with-nuxt
Published: 2019-08-30
If you're not familiar with [Phaser](https://phaser.io/), it's an open source framework for creating browser games in [Canvas](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) and [WebGL](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API). It comes with a lot of functionality out of the box, allowing a developer to easily implement sprites, sounds, animations, particles, cameras, physics, user input, and other game logic. It's primarily used to create web games, but it's a rendering engine with built-in state logic so it can be used for a wide array of applications that need to marry visuals with user interaction.
It's a lightweight engine that's used by companies and hobbyists alike to create first-class web games. Nowadays, creating a web game allows for easy portability to other platforms, like apps for iOS and Android. The web is becoming something like a _lingua franca_ for the many diverse technology platforms that we encounter in our lives.
I started playing around with Phaser simply out of fun. Last year, I worked with the [TIC-80 fantasy console](https://tic.computer/) and ended up writing an entire [blog post about using TIC-80 in JavaScript](). I used JavaScript at the time simply because I had no interest in Lua. The challenge of using TIC-80 was embracing the restraints of a fantasy console, in a celebration of how things _used to be_ coded. However, I quickly found myself fighting against those boundaries!
Now with Phaser, I'm working to leverage as much performance as possible from the browser. When I work with [Nuxt](https://nuxtjs.org/), I'm similarly doing everything I can to put the browser to work. For both types of frameworks, I'm trying to deliver a user experience that's engaging and memorable. Nuxt is incredibly good at maintaining state and displaying a responsive website, and Phaser is quite excellent at rendering visuals and understanding user input. I quickly saw a lot of potential in using them together, so I started integrating Phaser into this website — which is a Nuxt app!
By default, Nuxt will convert small images into a data URI, which allows the image to be inserted inline with the actual HTML and eliminates the need for an HTTP request. This can result in faster loading times and is a common practice on modern websites. It uses Base64 to encode binary data, and the resulting text has nothing but letters, numbers and the symbols "+", "/" and "=". It's a convenient way to transmit binary data because those 64 characters are common in many character sets and there's a low chance that your data will be corrupted on the other end of the wire. Base64 maps 3 bytes (8 x 3 = 24 bits) in 4 characters that span 6 bits (6 x 4 = 24 bits). The result looks something like "AAAGAAAABgCAYAAADimHc4AAA" and the image size actually becomes 4/3 = 1.3333333 times the original as a result of the encoding. For many web developers, eliminating the HTTP request outweighs the image bloat, especially when used only for small images. That's why Nuxt will automatically convert your small images into data URIs during its default [webpack](https://webpack.js.org/) build process.
Base64 images can affect SEO because the encoded image will not be indexed by Google, so they won't show up in a Google image search, or any other search engine. There are many unimportant images on a page that will not affect you or your traffic if they weren't indexed, like social icons. All those little Twitter, Google+, Facebook, etc. images are really small and have no need to be indexed. In fact when you really look at the images on your page you may find many images that fall into this category. Images like those are just not important to your SEO but are still slowing down your pages. A page could have 8 or 12 unimportant images and each of those is an additional HTTP request that can be avoided with Base64.
So if you use Phaser on a Nuxt app with this default configuration, your small image resources will be compiled into data URIs. When your application attempts to load those assets, your console is going to throw this error:
```
Local data URIs are not supported
```
Why does this happen? Like mentioned above, data URIs aren't necessarily more performant _just_ because they eliminate an HTTP request. In the age of HTTP/2, we can send multiple requests in parallel over a single TCP connection in the browser. By encoding your images, you're bloating them and putting more strain on the end user's processor to run the application. This is going to throttle performance because your images are larger (due to the compression bloat) and _then_ they have to be converted from Base64 back into a binary format, and _then_ the image data must be extracted by Phaser. By converting an image into an data URI, you add an extra step to the process — and your users have to pick up the tab!
It's easy to extend the default webpack build in Nuxt, but in this case we can't just push a new build rule for images in _nuxt.config.js_, because the default loader configuration already handles images. Adding a new build rule wouldn't prevent the first one from converting your small images into data URI. Here's what that default configuration looks like:
```
[
{
test: /\.(png|jpe?g|gif|svg|webp)$/,
loader: 'url-loader',
query: {
limit: 1000, // 1kB
name: 'img/[name].[hash:7].[ext]'
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
query: {
limit: 1000, // 1kB
name: 'fonts/[name].[hash:7].[ext]'
}
}
]
```
For both loaders, everything below 1 KB will be inlined as a Base64 data URI. The second loader configuration is for fonts and we can leave it alone. But the first loader configuration is for images. We need to exclude our Phaser assets directory from that first loader with a new rule in _nuxt.config.js_. While we're at it, we'll add a rule to load those Phaser assets with [file-loader](https://github.com/webpack-contrib/file-loader):
```
build: {
extend(config, ctx) {
// exclude Phaser image assets from url-loader
config.module.rules.forEach(rule => {
if (rule.test.toString().includes('png')) {
rule.exclude = /phaser/
}
})
// load Phaser image assets
config.module.rules.push({
test: /\.(png|jpe?g|gif|svg|webp)$/i,
loader: 'file-loader',
options: {
include: /phaser/,
name: '[path][name].[ext]'
}
})
}
}
```
We need to also add a build rule for handling our audio assets. Otherwise, Nuxt won't be able to load an mp3 or an [Ogg](https://en.wikipedia.org/wiki/Ogg):
```
config.module.rules.push({
test: /\.(ogg|mp3|wav|mpe?g)$/i,
loader: 'file-loader',
options: {
name: '[path][name].[ext]'
}
})
```
Now that we're correctly loading our assets, we need to create a plugin (_@/plugins/phaserLoader.js_) that shows Nuxt how to load a Phaser game:
```
import Vue from 'vue'
Vue.prototype.$phaserLoader = {
loadGame(loaderKey) {
if (process.client) {
return require(`@/assets/phaser/${loaderKey}/game`)
}
}
}
```
Once you do that, don't forget to include it in your _nuxt.config.js_:
```
plugins: [
{ src: '~/plugins/phaserLoader.js' }
]
```
By injecting this loader into the Vue scope, you can easily load any Phaser game in a component by providing a _loaderKey_ to the _loadGame_ function. We must confirm we're client-side before the function runs, because otherwise Nuxt will attempt to load Phaser server-side and will throw reference errors (there is no _window_, for example, which is critical for Phaser).
Now we have everything needed to assemble a very simple Vue component (called [_phaser-container_]()) that can load our Phaser game and pass the [Vuex](https://vuex.vuejs.org/) _store_ to it:
```
export default {
name: 'PhaserContainer',
props: {
width: {
type: Number,
default: 800
},
height: {
type: Number,
default: 600
},
pageContainer: {
type: String,
default: 'container'
},
phaserContainer: {
type: String,
default: 'phaser-container'
},
fixedSize: {
type: Boolean,
default: false
},
game: {
default() {
return {}
}
}
},
destroyed() {
this.gameObject.destroy()
},
mounted() {
this.$nextTick(() => {
// get page's main container (for sizing)
const pageContainer = document.getElementsByClassName(
this.pageContainer
)[0]
// launch game with resizing
if (this.fixedSize === false) {
this.gameObject = this.game.launch({
width:
pageContainer.clientWidth < this.width
? pageContainer.clientWidth
: this.width,
height:
pageContainer.clientHeight < this.height
? pageContainer.clientHeight
: pageContainer.clientWidth * 0.7 < this.height
? pageContainer.clientWidth * 0.7
: this.height,
parent: this.phaserContainer,
store: this.$store ? this.$store : null
})
// launch game without resizing
} else {
this.gameObject = this.game.launch({
width: this.width,
height: this.height,
parent: this.phaserContainer,
store: this.$store ? this.$store : null
})
}
})
}
}
```
The component can also be easily installed from npm ([@andrewzigler/phaser-container](https://www.npmjs.com/package/@andrewzigler/phaser-container)) and used right in your Vue or Nuxt application. Here's an overview of the component:
```
width: (width of game in pixels, default: 800)
height: (height of game in pixels, default: 600)
pageContainer: (class name of parent element, for determining max sizes, default: 'container')
phaserContainer: (ID name of element to contain Phaser element, default: 'phaser-container')
fixedSize: (if true, prevents the component from resizing based on pageContainer, default: false)
game: (imported Phaser game file)
store: (Vuex store, default: undefined)
```
In the mounted hook, we're finding the current size of the bounding container (_pageContainer_) and then resizing the Phaser game based on those dimensions. This could be useful to creating a responsive Phaser game that works across different displays. You can also disable the resizing by changing _fixedSize_ to true. In the example above, _payload_ is passed to the _game_ property. On my example page, _payload_ is determined by a computed function:
```
computed: {
componentPayload() {
switch (this.exampleComponentProps.type) {
// if the page is loading a Phaser game
case 'phaser': {
this.$store.commit(
'initializePhaser',
this.exampleComponentProps.loaderKey
)
return this.$phaserLoader.loadGame(
this.exampleComponentProps.loaderKey
)
}
default:
return {}
}
}
}
```
You'll recognize our _phaserLoader_ from before. Here it's being used to actually load the game file so our page can use it! While not passed to the actual component, the _phaser-container_ component will attempt to load your store into your Phaser game. It does this by appending the store to the Phaser game's [registry](https://photonstorm.github.io/phaser3-docs/Phaser.Scene.html#registry) prop, which is an instance of _DataManager_, which has an _EventEmitter_. Since your store will be appended to Phaser's global event manager, you can easily set up an event bus to connect reactive data between Phaser and Nuxt.
Here's an example of how you can put this all together:
_exampleGame/game.js_:
```
import Phaser from 'phaser'
import BounceScene from './scenes/BounceScene'
import event from './event'
function launch({
width = 800,
height = 600,
parent = 'phaser-container',
store
}) {
const game = new Phaser.Game({
type: Phaser.AUTO,
width,
height,
parent,
physics: {
default: 'arcade',
arcade: {
gravity: { y: 400 }
}
},
scene: [BounceScene]
})
// replace the EventEmitter on the DataManager with our own imported EventEmitter
game.registry.events = event
// append the Vuex store to EventEmitter
game.registry.events.store = store
// if there is no pre-existing game state, initialize it
if (!store.getters.phaser.exampleGame.bounces) {
event.store.commit('savePhaser', {
gameName: 'exampleGame',
prop: 'bounces',
value: 0
})
}
return game
}
export default launch
export { launch }
```
_exampleGame/events.js_:
```
const { Events } = require('phaser')
const events = new Events.EventEmitter()
events.on('bounce', () => {
let value = events.store.getters.phaser.exampleGame.bounces
value++
events.store.commit('savePhaser', {
gameName: 'exampleGame',
prop: 'bounces',
value
})
})
export default events
```
_exampleGame/scenes/BounceScene.js_:
```
import { Scene } from 'phaser'
import background from '../assets/background.png'
import ball from '../assets/ball.png'
import bounceMp3 from '../assets/bounce.mp3'
import bounceOgg from '../assets/bounce.ogg'
export default class BounceScene extends Scene {
constructor() {
super({ key: 'BounceScene' })
}
preload() {
this.load.image('background', background)
this.load.image('ball', ball)
this.load.audio('bounce', [bounceMp3, bounceOgg])
}
create() {
this.add.image(400, 300, 'background')
const ball = this.physics.add.image(400, 200, 'ball')
ball.setCollideWorldBounds(true)
ball.body.onWorldBounds = true
ball.setBounce(1)
ball.setVelocity(300, 50)
this.sound.add('bounce')
this.physics.world.on('worldbounds', () => {
this.registry.events.emit('bounce')
this.sound.play('bounce', { volume: 0.6 })
})
}
}
```
Now that you can integrate your Phaser games into Vue and Nuxt, what will you make? Phaser continues to only get bigger and better. [Richard Davey (also known as Photonstorm)](https://twitter.com/photonstorm) just announced this month that development will soon begin for Phaser 4, which will be supported in part by the Facebook Gaming and Instant Games teams. What's in store for the next major version of the framework? More details will be coming in the future, but in the meantime don't miss [their announcement](https://madmimi.com/p/4f5f0f)!
# Adding Auth0 to a Nuxt app
Source: https://www.andrewzigler.com/feed/adding-auth0-to-a-nuxt-app
Published: 2019-07-22
My latest web project required adding security and user authentication to a [Nuxt](https://nuxtjs.org/) app. The app is served as a static generated (pre-rendered) site on [Netlify](https://www.netlify.com/), which then "unfolds" into a full-fledged app upon loading. From then on, each successive route is explored on the live app in the user's browser and the user is therefore no longer navigating static files. This approach provides many benefits for SEO because the content of each discrete page is already embedded in a static generated file that represents the resource, and this improves both load times and Google's understanding of our website.
I wanted my guests to create accounts and interact with my new website using an identity. This would also facilitate testing and debugging by allowing me to toggle certain states onto some users (such as admins and beta testers). After some initial research, I settled on [Auth0](https://auth0.com/) and decided to use the official Nuxt _[auth-module](https://auth.nuxtjs.org/)_. I went with Auth0 because it integrates easily with popular social media logins like Facebook, Google and more. User accounts are passwordless as a result, but you can also allow users to create accounts not tied to any pre-existing profiles. Auth0 will even host those user profiles in a database for you. With a free account you can handle up to 7,000 active users across two social media logins, which is more than enough for what I'm working on.
To begin, I installed the _auth-module_ package and configured it for my application:
```
auth: {
strategies: {
auth0: {
domain: 'YOUR_AUTH0_DOMAIN',
client_id: 'YOUR_CLIENT_ID'
}
},
redirect: {
callback: '/login/redirect',
home: '/profile'
},
watchLoggedIn: false
}
```
I'm using slightly different pages to facilitate my redirects, so I defined them here. I have also toggled off a property that automatically redirects on logout. This was causing complications with my static pages, which I'll talk about below. Then on each page that I wanted to require user authentication, I include the middleware:
```
middleware: ['auth']
```
To initiate the login flow for a user, a button can use the Auth0 strategy we configured above with the module's _auth_ instance, which is globally injected. You could even use the instance's properties to conditionally show the button to users that are not logged in, like so:
```
Log in
```
Similarly, you can use the _auth_ instance to facilitate logging the user out:
```
Log out
```
But my approach for logging out ultimately involved wrapping that function and manually redirecting the user myself, to easily accommodate the project whether it's hosted locally or in production. The types of URLs the user might access the app from were also whitelisted in Auth0.
```
methods: {
logout() {
this.$auth.logout()
window.location.href = `https://YOURAUTHDOMAIN.auth0.com/v2/logout?returnTo=${location.protocol.substr(
0,
location.protocol.length - 1
)}%3A%2F%2F${location.host}`
}
}
```
As I mentioned above, I encountered some funky behavior when testing _auth-module_ on my static pages. In dev mode, the app flow worked perfectly, even without using a logout wrapper or configuring it as to not automatically redirect the user. This was because the login flow redirected the user to Auth0 for authentication, and upon returning to the app they were actually presented with a static page. This static page would then leap into action, but it would lose the user's authentication in the process. The result was a broken login process when uploaded to Netlify, which was no good! To fix this, I implemented mounted event hooks on the login pages. This is the hook for the actual login page, which automatically redirects the user if they're authenticated and sitting on the login page:
```
mounted() {
// schedule a redirect depending on login status
setTimeout(() => {
// only redirect if a logged-in user is lingering here
if (this.$route.path.includes('/login')) {
if (this.$auth.user) {
this.$router.push('/profile')
}
}
}, 1.5 * 1000)
}
```
When Auth0 redirects the user, it passes the authentication token in as a URL query parameter back to the website. Since the _auth_ instance isn't instantiated yet upon returning to the site, we need to manually check the URL and retrieve that parameter, which then gets passed into the _auth_ instance. We then manually fetch the user with the _auth_ instance and redirect if needed. This hook is for the login callback page, which is where the user lands upon being redirected back to the website from Auth0:
```
mounted() {
if (this.$route.hash) {
this.$auth.setToken(
'auth0',
`Bearer ${this.$route.hash.split('=')[1].split('&')[0]}`
)
}
// fetch user information from token
this.$auth.fetchUser()
// schedule a redirect depending on authentication results
setTimeout(() => {
// only redirect if the user is still here
if (this.$route.path.includes('/login/redirect')) {
if (!this.$auth.user) {
this.$router.push('/login')
} else {
this.$router.push('/profile')
}
}
}, 1.5 * 1000)
}
```
Now that the login flow is working, we can start using the authenticated user object to enrich our app. First, I created a "Provide user and app metadata" rule on Auth0 that will pass the user's metadata from Auth0 to the app so we can use it however we need. The user can view and edit their own metadata, and each user can also have app metadata that they cannot edit. User metadata can be for preferences or settings, and app metadata can be used for intrinsic properties and permissions. You can add metadata to a user in the Auth0 dashboard or via the API. Here is the Auth0 rule, which you can add in the dashboard from their website:
```
function (user, context, callback) {
const namespace = 'YOUR_DOMAIN';
context.idToken[namespace + 'user_metadata'] = user.user_metadata;
context.idToken[namespace + 'app_metadata'] = user.app_metadata;
callback(null, user, context);
}
```
With our new metadata, we can start building standard Vue functionality to compute values and display them within the app. Since I'm authenticating only with Facebook and Twitter, I have a simple check in place so I can quickly determine which of those sites the user logged in with. Auth0 gets the user's profile image from both sites, but I found the Facebook image to be very low quality and difficult to use. To rectify that, I created a computed property that will build a URL to a high-resolution version of that user's profile picture based on their Facebook ID (which we receive as part of their authentication). Here are some ideas to get you started:
```
computed: {
isAdmin(app) {
if (
app.$auth.user &&
app.$auth.user['YOUR_DOMAIN/app_metadata'] &&
app.$auth.user['YOUR_DOMAIN/app_metadata'].admin
) {
return true
} else {
return false
}
},
isFacebook: function(app) {
if (app.$auth.user && app.$auth.user.sub.split('|')[0] === 'facebook') {
return true
} else {
return false
}
},
facebookImage: function(app) {
return app.$auth.user
? 'https://graph.facebook.com/' +
app.$auth.user.sub.split('|')[1] +
'/picture?width=9999'
: ''
},
name: function(app) {
return app.$auth.user ? app.$auth.user.name : ''
},
}
```
Adding Auth0 to Nuxt ended up being a surprisingly complex and interesting task. This was my first time adding authentication to an app, and I found that most of the learning curve came from deciphering how authentication really works in the browser using cookies, and how that gets thrown into question when leveraging Nuxt to statically generate a site. I found more issues upon deploying the static files, which helped me further understand how it works under the hood. The power of Auth0 has truly impressed me, and I already have a wealth of ideas for using the authentication service in the future!
# Publishing my first-ever npm packages
Source: https://www.andrewzigler.com/feed/publishing-my-first-ever-npm-packages
Published: 2019-06-22
I'm continuing to work on a few different Nuxt projects after creating this website, but I quickly hit a point where I was tempted to copy-paste code that I had already written. Knowing that, I paused my projects and returned to my website with the goal of compartmentalizing those useful bits for reuse. After all, this is why the JavaScript ecosystem uses packages!
I extrapolated the structured data component that I previously created into a Vue component called [_structured-data_]() (published on npm as a scoped package: [_@andrewzigler/structured-data_](https://www.npmjs.com/package/@andrewzigler/structured-data)). To achieve this, I stripped out a lot of my customized code for my own website and created an interface that accepts that data instead. This challenged me to make my own utility more universal, resilient, and composable. Getting this package on npm proved to be a huge endeavor, as it was my first-ever package and I used Babel to transpile the exported Vue component. It ended up being a challenging task but I learned a lot about publishing modern JavaScript and single-file components in the process.
Next, I looked at how I was assembling page metadata and realized that reusing my freshly-researched, fully-compliant SEO strategy would be really helpful for my future projects. If anything, it would allow me to easily update and maintain that code over time. I moved all of those functions into their own framework-agnostic library called [_head-helper_]() and published it on npm as my second scoped package: [_@andrewzigler/head-helper_](https://www.npmjs.com/package/@andrewzigler/head-helper). This was a lot easier, as I didn't need to transpile and could simply export an ES6 class with static functions (JavaScript's equivalent of class methods).
After a little testing, I had this website back to full functionality but with all of its custom SEO functionality now imported from newly-externalized libraries. Now I can turn back to my other projects and easily import all of that hard work! It's great to find opportunities to reuse code I've created, and it shows I found a problem that needed addressing. I've grown rather fascinated with metadata and structured data so it was fun to apply those interests and create reusable tools.
# Enabling Google to crawl my Nuxt app for superior SEO
Source: https://www.andrewzigler.com/feed/enabling-google-to-crawl-my-nuxt-app-for-superior-seo
Published: 2019-05-23
_**2021 update:** in the time since this blog post was first published in 2019, I've rewritten my blog with Gatsby! That said, I'm keeping this post available for historical purposes._
I'm in the final stages of [turning my website into a Nuxt app]() and it feels like all my hard work is starting to pay off. When the site was previously statically generated on my computer using Hexo, it was relatively simple (but tedious) to also configure the pages for ideal SEO. Each page was a collection of partial templates that rendered with the page's details from the command line. From there, I created a simple JavaScript plugin for Hexo that allowed me to add the relevant metadata fields that I needed for each page, but it required defining those properties on the actual Markdown files for each blog post. Once again: tedious. Now that the website is an app, those pages are still statically generated but they're created via a web framework that's actually flattening each route of a single-page application into a static page. Nuxt is creating static HTML that transforms into a SPA upon loading. Talk about a turbo charge!
Single-page applications allow developers to connect data with a set of rules for display that data. Instead of tediously managing and curating each page as a standalone experience (and having to replicate any changes or fixes across all of those individual pages), I can define how the app should handle the content of the site depending on what it has available, what the user is seeking, and the size of the viewport. But single-page applications do all of this on the fly, so what happens when Google tries to crawl an SPA? The Googlebot understands and will parse JavaScript on a page, but that's not enough because an SPA usually lives entirely on just one page. Commonly, an SPA itself is emulating URLs in the browser using a router but it's not actually a collection of static page files for the Googlebot to crawl. When Google crawls an SPA, it only crawls the one page the actual app is embedded on, and even when that happens it's unlikely to render any of the dynamic goodies on the "home page" of that SPA. In short: Google sees next to nothing. _**2021 update:** the Googlebot has become considerably better at rendering pages in JavaScript (like SPAs) to read their contents and follow links. In many cases, it's no longer necessary to precompile an SPA in order for Google to read it. That said, there are still indexing benefits to generating a static site upfront!_
This is where we can leverage the power of Nuxt. Between a static HTML and a single-page application, Nuxt allows the best of both worlds because it pre-renders all pages to include the necessary HTML. Nuxt pre-renders each page server-side at the time of generation by querying the Prismic API to get my website's most up-to-date information, storing those records in the browser, and ultimately displaying the data in a variety of view states to the user depending on what route they're accessing. That generation is happening on Netlify anytime I edit my website's content on Prismic or commit changes to the app's GitHub repository, so the generated content is always the latest. Once the page loads, any further navigation by the user results in the Vue app taking control of the browser, as opposed to navigating from static file to static file. The result is a much faster and seamless experience, as the app's state can change much quicker and with less resources than a new page load. And all of this happens seamlessly.
With Nuxt generating every possible route for my app, next I had to ensure the right metadata appeared on each type of page. I did this using [_vue-meta_](https://vue-meta.nuxtjs.org/), which is included in Nuxt. I was able to define top-level metadata properties on the actual app, and pages downstream (children components) have their own overrides. The app uses the most specific metadata it can find for each route and component, so you can define something app-wide and then override it only where necessary by defining the property on a child. Using this concept, I sprinkled properties in the __ element for each part of my website. There was even more metadata available to pack into my individual blog posts, so more fields were applied to those.
Finally, I used [structured data](https://developers.google.com/search/docs/guides/intro-structured-data) to package all my available data into delicious morsels of rich markup for the Googlebot to parse. For example, when a blog post is displayed, the user sees the title, author, date, header image, and contents of that blog. Structured data allows the Googlebot to understand what information is actually being conveyed on each of my pages. I identified all of the possible types of [Schema structured data](https://schema.org/) from my website and then wrote JavaScript to embeds relevant structured data on each page.
As a result, my web app is not only blazing fast (and acing every [Google Lighthouse](https://developers.google.com/web/tools/lighthouse/) audit currently), but it's packed with contextual metadata and abiding by best SEO practices. Best of all, Google understands my site as well as any reader!
# Finding my style with Material Design
Source: https://www.andrewzigler.com/feed/finding-my-style-with-material-design
Published: 2019-04-30
_**2021 update:** in the time since this blog post was first published in 2019, I've rewritten my blog with Gatsby! That said, I'm keeping this post available for historical purposes._
As I continue [converting this website into a Nuxt app](), I’m encountering design decisions that permeate deep into the style of my actual website. It’s amazing how dramatic the decisions about padding, font, and color can impact the aesthetic and usability of an interface. At this point, I'm more inclined to use an established UI framework that’s been brainstormed and battle-tested into something more polished, as opposed to making something from scratch. As a result, I’ve started learning [Material Design](https://material.io/) as I assemble my website and blog with [Vuetify](https://vuetifyjs.com/en/) components. _**2021 update:** I've since learned a lot about styling and this website no longer uses Material Design! I've chosen to make my own styles instead._
Starting out, I spent a great deal of time creating mockups for my website in [Sketch](https://www.sketch.com/) with the [Material Theme Editor](https://material.io/tools/theme-editor/) plugin. I adapted my current website color palette into a new medley of pastels and grays that felt more modern when paired with the rounded corners of my new elements. One by one, I assembled the different components I would need in order to render my website on both desktop and mobile. It took some time, but I built mock-ups for every type of view for my website so I would be forced to make design decisions globally, not on the fly. I didn’t want to pigeonhole myself by making a quick decision that could ripple out and tarnish the entire design, so I worked on a canvas before I started writing HTML and CSS.
This approach worked well for me while learning the basics of Material Design, but as I finish this project and embark on my next endeavor, I know future implementations of Vuetify will be faster and cleaner as I continue to develop my own library of components. The power of prototyping in Sketch became evident when I wanted to make impactful changes, as I could globally change the colors, fonts, and other properties in a way that intelligently affects all of the components downstream. The plugin will even vertically re-center text if the new font has a different midline height. The ability to simulate a hot reload in Sketch enabled me to prototype ideas quickly and efficiently, but moving forward I'll likely just work directly in HTML and CSS.
My final challenge now is adapting text styles within the actual blog posts. I was using Markdown before, but now I’m using a rich text editor that actually serializes the content into HTML, and I can customize how that serialization is performed. I already have as much control as I need by styling the component that holds the actual blog post content, but I’ll also use the serializer to tag the generated HTML correctly and make special tweaks when appropriate. This will make the website appear more polished and professional.
In [my final blog post of this series](), I’ll be exploring the SEO changes I’ve made to the website as part of this conversion.
# Converting this website and blog into a Nuxt app
Source: https://www.andrewzigler.com/feed/converting-this-website-and-blog-into-a-nuxt-app
Published: 2019-03-31
_**2021 update:** in the time since this blog post was first published in 2019, I've rewritten my blog with Gatsby! That said, I'm keeping this post available for historical purposes._
At the end of 2017, [I built this blog]() with [Hexo](https://hexo.io/) and migrated the rest of my site to use the same Markdown format. Creating the website this way comes with a lot of positives. One of those positives is that I'm able to control the entire build locally on my computer in a very formulaic way. It’s been fairly easy to make small adjustments to the build process and the resulting files, but generating a static site by an admittedly simple method has also impeded my ability to make this website more dynamic and interesting. But that’s all changing now, as I’m converting my website and blog into a pre-rendered [Nuxt](https://nuxtjs.org/) app!
Due to the nature of the web, by the time you're reading this the entire site could already be this planned Nuxt app, or it could be something else entirely! _**2021 update:** how true indeed, as the website is now in Gatsby! _But as I write this, I’m still building the app locally and learning as I go. One of my objectives is to move the blog's build process off my hard drive and into a form of cloud hosting. To that end, I'm going to leverage the [JAMstack](https://jamstack.org/).
To house all of my content, I've adopted [Prismic](https://prismic.io/) as my headless CMS. Prismic itself is a crafty API that lets you define a data structure and then build documents using those custom templates. You can use their API to query, organize, and display your documents however you want. For my purposes, I created one template for blog posts and another for portfolio projects.
In building those templates, I had to account for all of the fields currently represented on my blog before migrating the contents of each Markdown files into Prismic by hand. I recently completed that migration and now I’m bringing all of the content together in Nuxt.
I've decided to build the blog from scratch with my own Vue components so I can have granular control over the results. I don't want to use a template or a starter because I see it as an opportunity to make something myself and learn how all of the parts work. Once I have an idea of how to approach it, I can look at a more polished project and compare their execution to mine._ _Since I’m converting a pre-existing website and the resulting structure is changing slightly, I'm also faced with challenge of redirecting URLs so no SEO is lost. Thankfully, since I’ll be using [Netlify](https://www.netlify.com/) to deploy the site now moving forward, I can easily set up those redirects with a file in the project repository.
When I get the site's framework finalized soon, the next task will be designing the pages. As a result, I’ll be taking a deep dive into styling Vue components in [my next post]()!
# Web Components: spoils of the browser wars
Source: https://www.andrewzigler.com/feed/web-components-spoils-of-the-browser-wars
Published: 2019-02-28
When discussed, “browser wars” typically has the connotation of our browsers fighting to get an edge on its competitors by developing or supporting new features. While the growing pains of this process have made designing for differing browsers sometimes tedious and unreliable, [the war seems to finally be coming to an end](https://www.dailymail.co.uk/sciencetech/article-6469151/The-end-browser-wars-Microsoft-switch-toapp-based-technology-Googles-Chrome.html) and it’s time to reap the rewards.
In 2019, [all modern browsers support ES6](https://caniuse.com/#search=es6), which allows for rapid development and cleaner syntax without needing to transpile for the browser. The ease of advanced JavaScript also brings with it more developed APIs and tools to support anything the website needs within the browser.
The rise of modules in the browser necessitates better encapsulation and versatility with how we solve web problems with JavaScript, and one solution is a new technology called [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components).
### **What are Web Components?**
Web Components are made of different web technologies that allow you to create reusable custom elements, and they'll be just as native as a _