{"slug": "a-hook-should-be-a-function-call-not-a-shell", "title": "A hook should be a function call, not a shell", "summary": "A developer moved thirteen of the shell-script hooks in workkit, a workflow kit for Claude Code, into a single plugin module loaded once per session, cutting each hook fire from about 90 ms at the median to near zero on an M1 Max. The same update routes the agent's GitHub bookkeeping through a workkit-agent[bot] GitHub App and replaces assignee-based claims with an agent:claimed label. A separate fix in the omega framework caps the Firebase emulator's auth-trigger worker pool with a mapLimited helper after a 91-user seed hit 52 workers and ran out of memory on a 7 GB CI runner.", "body_md": "This one covers October 14 to 21. Most of the week went into one idea: the small scripts that guard my Claude Code sessions should not each start a shell. The rest went into giving the agent its own name on GitHub, and into a framework bug that only showed up on a small CI runner.\n\n[workkit](https://github.com/itw-creative-works/workkit) is my workflow kit for Claude Code: GitHub issues as the work list, labels as the pipeline, and hooks that enforce the rules. A hook is a small program Claude Code runs on an event, like a prompt, a file edit, or a shell command. It can add context, warn, or block the action.\n\nUntil this week every hook was a shell script. Each fire went through a loader script, which started bash, which usually called jq to read the event, and often node for the real logic. That is three or four processes for one check.\n\nThe count adds up fast. Before version 0.79, every prompt I sent started four shell hooks. Every file edit started four more after the write. Session start ran four, and every subagent spawn ran three.\n\nSo I timed it. On my M1 Max, with other work running and the machine busy, one fire routed through the loader took about 90 ms at the median. A bare bash, jq and node chain alone took about 38 ms. A function call inside a process that is already running rounds to zero.\n\nClaude Code can now load a plugin module once per session and call it on each event. workkit 0.79 moved thirteen hooks into one such module, and fifteen live there now. My personal hooks did the same in my dotfiles: seven of them, including the ones that stamp the time and check each reply's format, run as one module.\n\nThe design rule that made the move safe: only one file, `register.js`, touches the host's interface. It gathers the facts a hook needs (a file's text, a `git` answer, a stored marker), and hands them to plain functions in `hooks/mods/<name>.js`. Those functions take plain data and return plain data. So the logic tests just `require()` them and need no Claude Code at all. Only a thin set of wiring tests runs through `claude plugin test`.\n\nThe hooks that guard shell commands, like the commit gate, stay shell hooks for now. They are next.\n\nworkkit has the agent do a lot of bookkeeping on GitHub: it claims an issue, parks it for my check with a comment, posts the proof, and closes it. Until 0.76 all of that posted as me. Reading an issue's history, I could not tell my decisions from the agent's notes.\n\nNow that bookkeeping posts as `workkit-agent[bot]`, a GitHub App. How the token gets there was the interesting part:\n\n`gh` shim on the PATH. A call that writes (a comment, a label, a close) gets the bot's token. A read, a commit and a push still run as me.\nThe claim also changed. It used to be the assignee field, which meant me. It is now an `agent:claimed` label, so the claim is the agent's, in plain sight.\n\n[omega](https://github.com/Omega-JS-Stack/omega) is my framework for building a brand's website, backend, desktop app and browser extension from one config. Its backend self-test seeds 91 test users. It created them all at once.\n\nEach new user fires an auth trigger, a function that runs on sign-up. The Firebase functions emulator starts one worker per trigger in flight, and it never caps that pool. On a 7 GB CI runner the test reached 52 workers and ran out of memory. A machine with more memory hides the problem.\n\nThe fix is a small helper, `mapLimited`, that runs the seed four at a time. The seed had a second bug too: it reported every user created even when the emulator had loaded no functions at all. A green seed was hiding a backend that never ran. Now a user whose sign-up trigger never finishes stops the seed and names the account.\n\nTwo more changes I like:\n\n`omega_` names. An old name does not quietly keep working: it throws, and the error names its replacement. `omega migrate` rewrites them for you.`workkit log --follow` tails it. It is the first place I look when a hook seems silent.\nThe remaining shell hooks, the commit gate among them, are next in line for the module. If you write hooks for an AI coding tool, I would like to hear how you keep them fast. Comments are open.", "url": "https://wpnews.pro/news/a-hook-should-be-a-function-call-not-a-shell", "canonical_source": "https://dev.to/ianwieds/a-hook-should-be-a-function-call-not-a-shell-3dkd", "published_at": "2026-10-21 19:57:00+00:00", "updated_at": "2026-10-21 20:11:27.036817+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["workkit", "Claude Code", "GitHub", "omega", "Firebase", "M1 Max", "itw-creative-works", "Omega-JS-Stack"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-hook-should-be-a-function-call-not-a-shell", "markdown": "https://wpnews.pro/news/a-hook-should-be-a-function-call-not-a-shell.md", "text": "https://wpnews.pro/news/a-hook-should-be-a-function-call-not-a-shell.txt", "jsonld": "https://wpnews.pro/news/a-hook-should-be-a-function-call-not-a-shell.jsonld"}}