Compare commits

...
222 Commits
Author SHA1 Message Date
Firehose Bot f522895e4e fixed link to tag and image 2026-08-12 23:48:12 +02:00
Firehose Bot 3308299d3c pi.dev and open weights 2026-08-11 08:19:47 +02:00
Firehose Bot 4fa0f85847 Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-08-08 12:57:50 +02:00
mostalive a426a2b61b retort blog post 2026-07-30 10:15:43 +01:00
Firehose Bot d6dddbc9fb session transcript in pi 2026-07-28 13:15:24 +01:00
Firehose Bot 20de33720a freewriting 2026-07-27 16:18:09 +01:00
Firehose Bot f22a8cbf37 swim the test part two 2026-07-27 16:16:49 +01:00
Firehose Bot d7c6bea7fa good enough for now 2026-07-27 11:14:22 +01:00
Firehose Bot 419abc6b92 swim the test zeroth draft 2026-07-27 10:47:17 +01:00
Firehose Bot 17d7ad760d fix sigil error - no comma needed in word list 2026-07-19 08:52:31 +01:00
Firehose Bot 2e3187597d does ai assistance support tiny open source projects? 2026-07-18 10:14:26 +01:00
Firehose Bot 1554773544 pi coding agent skill 2026-07-16 18:29:32 +01:00
Firehose Bot 761d6fe2ce draft for pi skill 2026-07-16 13:21:03 +01:00
Firehose Bot 4ead5702fb Merge branch 'main' of dokku:firehose.apps.sustainabledelivery.com 2026-07-13 15:02:22 +01:00
Firehose Bot 4885c94b72 old conflicts and version update 2026-07-13 15:01:19 +01:00
Firehose Bot 7af61a917f Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-07-13 14:58:40 +01:00
Firehose Bot 337a0a61d8 Synthetic TDD - Vocabulary 2026-07-13 14:49:01 +01:00
mostalive 990af18ef8 v0.2.0 — RSS subscribe links
- Add release note for v0.2.0
- Bump version from 0.1.0 to 0.2.0
2026-07-08 17:00:11 +01:00
mostalive e385710498 Swap to classic RSS waveguide icon for better recognizability
Replace the broadcast-style SVG icon with the traditional RSS
symbol (dot + two concentric arcs) — the standard feed icon
used across browsers and feed readers.
2026-07-08 17:00:11 +01:00
mostalive 855b6cb590 Add RSS subscribe links with icon across blog pages, footer, and head
- Add <.rss_icon> inline SVG component in core_components.ex
- Inject RSS <link> tags into <head> on blog pages for auto-discovery
- Show RSS icon next to blog title on index and tag pages
- Show RSS icon on post pages next to back-link
- Add Subscribe section in footer with links to both feeds
- Wire feed URL through blog controller (index, show, tag actions)

174 tests pass, credo clean.
2026-07-08 17:00:11 +01:00
mostalive 4c8d1dcee3 add serve target to Makefile 2026-07-08 14:23:39 +01:00
mostalive 001bf684b7 add description 2026-07-08 14:23:39 +01:00
mostalive 9238ecb7f3 add serve target to Makefile 2026-07-08 14:22:09 +01:00
mostalive 10c02f5c6a add description 2026-07-07 13:48:41 +01:00
mostalive 63ae78129f fixed test - clicking on line instead of parts in svg 2026-07-07 13:36:09 +01:00
mostalive a1d1107129 Run tests when resetting pi, so we do not forget to run tests 2026-07-07 13:36:09 +01:00
Firehose Bot 32f75d63d3 upgrade nimble_publisher to 2.0 for mdex_native (replaces deprecated earmark) 2026-07-07 13:36:09 +01:00
Firehose Bot 29754ff651 fix microprints_live tests: syntax error, brittle selectors, and layout assertions
- Add missing 'do' keyword on describe block (SyntaxError)
- Replace brittle SVG rect DOM selector with direct render_click event
  trigger to avoid AmbiguousError from multi-segment rects
- Fix layout test: render/1 returns LV inner HTML only, not the
  layout wrapper — check for content actually present in output
2026-07-07 13:36:09 +01:00
Firehose Bot 9839899eae ignore logs 2026-07-07 13:36:09 +01:00
Firehose Bot 5afc935ed8 delete log 2026-07-07 13:36:09 +01:00
Firehose Bot 6c3051d618 Reapply "Fix dashboard dark mode readability by using theme-aware text colors"
This reverts commit 76255c7641.
2026-07-07 13:36:09 +01:00
Firehose Bot 7552dd85ee Revert "add clean build"
This reverts commit 679147dcd5.
2026-07-07 13:36:09 +01:00
Firehose Bot 639ff5e781 Revert "ignore log files"
This reverts commit c53fd9d441.
2026-07-07 13:36:09 +01:00
Firehose Bot 97dea4b544 ignore log files 2026-07-07 13:36:09 +01:00
Firehose Bot d2152dbcea add clean build 2026-07-07 13:36:09 +01:00
Firehose Bot b71f4649e2 Revert "Fix dashboard dark mode readability by using theme-aware text colors"
This reverts commit 2a0634b820.
2026-07-07 13:36:09 +01:00
Firehose Bot 07aca93e7c Fix dashboard dark mode readability by using theme-aware text colors
- Use  and  instead of hardcoded zinc colors in EditorDashboardLive
- Apply same fix to MicroprintsLive description paragraph
- All titles now respect the active theme and remain readable in both light and dark modes
2026-07-07 13:36:09 +01:00
Firehose Bot 188c556233 Fix demo user confirmation to allow password login
The seeds file was creating an unconfirmed user and then setting a password,
which conflicts with Phoenix's auth system (magic link login is not allowed
for unconfirmed users with passwords).

This fix ensures the demo user is confirmed before setting their password,
so they can log in via both magic link and password.

Demo credentials: demo@example.com / password123!
2026-07-07 13:36:09 +01:00
Firehose Bot efbd314d33 Add app layout to live_session for dashboard menu
Fix the dashboard missing menu by explicitly setting layout: {FirehoseWeb.Layouts, :app} in the live_session.
This ensures the navigation menu is rendered on the authenticated editor dashboard LiveView.
2026-07-07 13:36:09 +01:00
mostalive c8864c5b3b fixed test - clicking on line instead of parts in svg 2026-07-07 13:33:50 +01:00
mostalive 716bfb4b12 Run tests when resetting pi, so we do not forget to run tests 2026-07-07 13:32:55 +01:00
mostalive 6b5329bd75 moralistic programming 2026-07-07 13:32:55 +01:00
mostalive 8479a2fc1f moralistic programming 2026-07-07 12:34:48 +01:00
Firehose Bot 51ba85d492 upgrade nimble_publisher to 2.0 for mdex_native (replaces deprecated earmark) 2026-07-06 18:18:12 +01:00
Firehose Bot 39973c3414 fix microprints_live tests: syntax error, brittle selectors, and layout assertions
- Add missing 'do' keyword on describe block (SyntaxError)
- Replace brittle SVG rect DOM selector with direct render_click event
  trigger to avoid AmbiguousError from multi-segment rects
- Fix layout test: render/1 returns LV inner HTML only, not the
  layout wrapper — check for content actually present in output
2026-07-06 18:09:26 +01:00
Firehose Bot b55de0a677 ignore logs 2026-07-06 18:08:09 +01:00
Firehose Bot 85f0d86016 delete log 2026-07-06 18:07:43 +01:00
Firehose Bot 2ba8457b14 Reapply "Fix dashboard dark mode readability by using theme-aware text colors"
This reverts commit 76255c7641.
2026-07-06 17:02:13 +01:00
Firehose Bot e0c7abc417 Revert "add clean build"
This reverts commit 679147dcd5.
2026-07-06 17:02:11 +01:00
Firehose Bot f100d8d211 Revert "ignore log files"
This reverts commit c53fd9d441.
2026-07-06 17:02:10 +01:00
Firehose Bot c53fd9d441 ignore log files 2026-07-06 16:57:02 +01:00
Firehose Bot 679147dcd5 add clean build 2026-07-06 16:56:54 +01:00
Firehose Bot 76255c7641 Revert "Fix dashboard dark mode readability by using theme-aware text colors"
This reverts commit 2a0634b820.
2026-07-06 11:54:56 +01:00
Firehose Bot 2a0634b820 Fix dashboard dark mode readability by using theme-aware text colors
- Use  and  instead of hardcoded zinc colors in EditorDashboardLive
- Apply same fix to MicroprintsLive description paragraph
- All titles now respect the active theme and remain readable in both light and dark modes
2026-07-06 11:14:13 +01:00
Firehose Bot 2be39c3273 Fix demo user confirmation to allow password login
The seeds file was creating an unconfirmed user and then setting a password,
which conflicts with Phoenix's auth system (magic link login is not allowed
for unconfirmed users with passwords).

This fix ensures the demo user is confirmed before setting their password,
so they can log in via both magic link and password.

Demo credentials: demo@example.com / password123!
2026-07-06 10:55:15 +01:00
Firehose Bot ccb52be9db Add app layout to live_session for dashboard menu
Fix the dashboard missing menu by explicitly setting layout: {FirehoseWeb.Layouts, :app} in the live_session.
This ensures the navigation menu is rendered on the authenticated editor dashboard LiveView.
2026-07-06 10:37:08 +01:00
Firehose Bot fe156ef81e Add Dashboard nav link for authenticated users
- Add conditional "Dashboard" link in navbar that shows when a user is logged in
- Tests verify the link is hidden for unauthenticated users and visible when authenticated
- Investigation reports for Q1 (drafts/scheduled), Q2 (RSS), Q3 (email subs)
2026-07-06 09:11:13 +01:00
Firehose Bot f6229ebf77 Drag and drop images for blog posts 2026-07-03 14:05:34 +01:00
Firehose Bot bcc973f9f7 correct dir-locals for dragging 2026-06-28 22:40:08 +02:00
Firehose Bot 98d0debb43 add file location 2026-06-28 10:16:02 +02:00
Firehose Bot 85be71fe1a add intro 2026-06-27 10:27:21 +02:00
Firehose Bot e63c2abff2 an example would be handy right about now 2026-06-27 10:20:53 +02:00
Firehose Bot a9f3d013a7 experimental file that shows images in markdown 2026-06-26 20:50:37 +02:00
Firehose Bot 881fce5023 load images - blog post 2026-06-26 20:49:37 +02:00
Firehose Bot 38ca16bbca do not open editor - vim inside emacs is annoying 2026-06-26 16:12:50 +02:00
Firehose Bot 9d2565a8b4 Merge branch 'main' of dokku:firehose.apps.sustainabledelivery.com 2026-06-20 22:07:22 +01:00
Firehose Bot 817a227309 publish 2026-06-20 22:06:41 +01:00
Firehose Bot eb3af61884 blog post about explaining image models 2026-06-20 22:05:03 +01:00
Firehose Bot ef51fdcfff Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-06-20 17:48:45 +01:00
Firehose Bot 3859c836e5 publish AI zombies retro actively 2026-06-20 17:48:27 +01:00
mostalive 1cf5a39acd fix up quotes 2026-05-21 13:00:33 +01:00
mostalive 1d5358d215 add Further Reading section 2026-05-21 12:16:49 +01:00
Firehose Bot 6b201e7e63 Set up Playwright for browser testing
- Playwright config for local browsers (Firefox by default) and Docker Chrome
- docker-compose.chrome.yml for browserless/chrome container (arm64)
- 4 connectivity smoke tests (home, contact, blog pages)
- 3 auth tests (redirect, login, logout) with auth helpers
- Custom fixtures with remote CDP connect support
- Shell scripts for start/stop/full Docker Chrome workflow
- Makefile targets: playwright, playwright-docker, playwright-full
2026-05-18 23:12:42 +01:00
Firehose Bot 5d20a21499 Add init script for demo skill
Installs chrome and rodney
2026-05-18 22:06:20 +01:00
Firehose Bot a4bca75946 Fix credo quote-sigil warning in microprints_live_test.exs
Replace double-quoted string with escaped quotes in element selector
with a ~s sigil to satisfy Credo's 'More than 3 quotes found inside
string literal' readability check.

Includes formatting changes from mix format (ran via make check).
2026-05-18 21:35:28 +01:00
Firehose Bot a9e746d520 scroll and highlight selected line
added line numbers to source view, fixed css selectors
2026-05-18 21:31:01 +01:00
Firehose Bot 790d2f0e1d Remember microprint state on reload 2026-05-18 20:17:38 +01:00
Firehose Bot 7afe5c1fe8 test(microprints): make expand-switch test resilient to file changes
Remove hardcoded module names (FirehoseWeb.MicroprintsLive,
Firehose.Application) from the expand-switch test. Now verifies:
- Each file's source renders when expanded (generic defmodule check)
- Source content differs between files (catches stale content bug)
- Button states update correctly (collapse/expand)
2026-05-18 18:09:55 +01:00
Firehose Bot 7c06204ac2 fix(microprints): fix source viewer showing stale content when switching files
The source_viewer component used static DOM IDs (id='source-viewer')
with phx-update='ignore'. When switching expanded files, LiveView
reused the same DOM element but phx-update='ignore' prevented content
from being updated, showing the previous file's source.

- Override source_viewer/1 in MicroprintsLive with unique per-file IDs
  generated via :erlang.phash2(file_path)
- Add test verifying expand-switch shows correct source content
- Add test for highlight/expand coupling (collapse when highlighting
  a different file)
- All 160 tests pass
2026-05-18 18:04:47 +01:00
Firehose Bot 36bdc0610e fix(microprints): collapse expanded file when highlighting a different file
- Add test in microprints_live_test.exs that documents the uncoupled state bug
- Fix handle_event("highlight_line") to collapse expanded_path when
  highlighting a line in a different file
- All 159 tests pass
2026-05-18 17:28:30 +01:00
Firehose Bot eb97015dc0 Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-05-18 16:53:09 +01:00
mostalive e53445fc1e demos did not convey needed feedback 2026-05-15 16:23:16 +01:00
mostalive eec0ff67b3 fix(microprints): pass unique id to source_viewer to fix DOM state
- source_viewer now receives id="source-viewer-#{path}" for unique DOM ids
- Fixes stale content bug when switching between expanded files
2026-05-15 16:21:11 +01:00
mostalive 43aaa6bdbe fix(microprints): always show microprint SVG, source below when expanded
Layout: button → microprint (always) → source viewer (conditional)
2026-05-15 16:21:11 +01:00
mostalive aaf4c6a2c7 refactor(microprints): move expand/collapse button above microprint
- Button is now on top of the card, matching qwan-tracker layout
- Microprint SVG shown when collapsed, source viewer when expanded
- Fixes DOM confusion from duplicate source-viewer IDs by only
  rendering one source_viewer at a time
2026-05-15 16:21:11 +01:00
mostalive 3434042aed fix(microprints): resolve absolute paths correctly for source viewer
- Use Path.expand(Path.join(app_root, path)) to correctly resolve both
  app files (lib/...) and blogex files (../blogex/...)
- Previously Path.join alone did not resolve '..' components, causing
  all source viewers to show the wrong file content
2026-05-15 16:21:11 +01:00
mostalive f01cdf918a fix(microprints): limit page to 2 most recently modified files
- Sort source files by mtime (newest first) and take only the first 2
- This limits the page scope for easier testing/investigation
- Temporary limit; will be 10 most recently changed files later
- Updated tests to work with the 2-file limit
2026-05-15 16:21:11 +01:00
Firehose Bot 47292dbb32 add npm 2026-05-15 14:59:42 +01:00
Firehose Bot 801fac5a41 Add npm install to Dockerfile for dokku 2026-05-15 14:58:26 +01:00
Firehose Bot fc583f19be microprints as https dep 2026-05-15 11:53:36 +01:00
Firehose Bot 5a9940c082 publish init script blog post 2026-05-15 11:43:59 +01:00
Firehose Bot 99e25d175e Add init.sh script and its' draft blog post 2026-05-15 11:12:15 +01:00
Firehose Bot d6df39e90f Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-05-14 17:04:21 +01:00
mostalive 86a924d92a setting up microprints transcript 2026-05-14 16:58:26 +01:00
Firehose Bot dc8c92d189 Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-05-14 14:05:02 +01:00
mostalive 54651d2349 Add highlight.js syntax highlighting to source viewer
- Install highlight.js via npm with 8 language definitions
- SourceViewer hook applies syntax highlighting on mount/update
- Atom One Dark theme for dark mode, Atom One Light for light mode
- Add hooks directory, package.json, and package-lock.json
- Add demo document and screenshots
- Add rodney-docker.md documentation
- Ignore .rodney/ chrome data directory
2026-05-14 13:17:10 +01:00
mostalive fa4825d76d chromium in docker 2026-05-14 12:30:22 +01:00
mostalive 237dcb48b2 Port DEMO skill from claude to pi
And particular for this project. I am now reading more markdown, making
smaller steps. So the installation instructions went to the README.

Hmm. having just written about init scripts, the installation may be
best moved to an init script in the skill directory.
2026-05-14 11:18:29 +01:00
mostalive 7a50f2d4e7 WIP microprints source now showing
also added go to install showboat and rodney for the next steps.

microprints work in qwan tracker, but not in firehose. some of the
rendering is in the project, maybe the library should provide sample
webpages.
2026-05-14 11:15:37 +01:00
Firehose Bot b2d3bdef19 add scripts/show_drafts and scripts/scheduled for listing unpublished blog posts 2026-05-13 21:48:32 +01:00
mostalive 061787e897 Feature: OG Social media cards
Not Original Gangsta, but ObjectGraph

Reason: images were showing on LinkedIn, but small.
2026-05-10 21:34:07 +01:00
Firehose Bot 764585c642 Merge branch 'main' of dokku:firehose.apps.sustainabledelivery.com 2026-05-08 11:52:18 +01:00
Firehose Bot 987e567683 Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-05-08 11:07:13 +01:00
Firehose Bot 468911c610 ai zombies draft 2026-05-08 11:06:58 +01:00
mostalive 77b1972204 add mimemail dep 2026-05-07 17:05:52 +01:00
mostalive 5d8337c3e6 typo 2026-05-07 17:02:00 +01:00
mostalive b45e2af4dc configure mail 2026-05-07 16:34:55 +01:00
Firehose Bot a83634da36 refactor(Blogex.LinkValidator): simplify dead code and naming
- Drop redundant :ok return in _validate_links/2 (blog.ex)
- Remove dead HTML link regex from extract_links/1 (body is raw markdown)
- Rename slug_slug_end/1 to slug_end/1
- Simplify parse_blog_link/1 to return {blog_id, slug}, removing
  parse_query_fragment/1 and dead case branches
2026-05-07 13:24:11 +01:00
Firehose Bot 1f0423841a chore: add yaks for LinkValidator simplifications 2026-05-07 12:48:40 +01:00
Firehose Bot 83eb8f7d50 chore: commit misc untracked files (llm-metrics, mise.toml, block-agent-idea.html) 2026-05-07 11:57:24 +01:00
Firehose Bot 2b7cf0a3b9 chore: apply mix format to blogex test files and router 2026-05-07 11:57:17 +01:00
Firehose Bot ab7a520e9e Add compile-time link validation for blog post internal links
- Add Blogex.LinkValidator module to validate /blog/{id}/{slug} semantics
- Add Blogex.LinkError exception with actionable error messages
- Integrate validation into Blogex.Blog via @before_compile callback
- Add unit tests (34) and integration tests (4) for link validation
- Add test fixtures (valid/invalid posts) in blogex/priv/blog/test/

Closes: validate-internal-link-semantics-in-blog-post-markdown-bodies-at-compile-time-h3hb
Closes: define-link-semantic-validation-logic-in-blogex-7syv
Closes: write-tests-for-link-semantic-validation-y30h
Closes: integrate-link-validation-into-blogexblog-compile-time-macro-1205
2026-05-07 11:56:54 +01:00
mostalive c535e63b70 add screenshots for yaks 2026-05-06 15:50:53 +01:00
mostalive 2d9c28d395 notes 2026-05-06 15:48:56 +01:00
mostalive d89420b2f8 start drafting that blog on yaks 2026-05-06 15:48:56 +01:00
mostalive 6bfb50ee02 Add yak show and context to instructions 2026-05-06 15:48:56 +01:00
mostalive 277c4bd9be publish transcripts - html in gitea unreadable 2026-05-06 14:24:53 +01:00
mostalive b43a55e1b4 update new post script with correct author 2026-05-06 14:09:33 +01:00
mostalive 71328a27e8 building notification extension for linux - transcript 2026-05-06 14:09:19 +01:00
mostalive fe30189d54 reflect on yak usage with qwen3.6-35B 2026-05-06 14:04:39 +01:00
mostalive 11396e37a8 fix module aliasing issues 2026-05-05 23:04:01 +01:00
mostalive 0a7ad6af8a docs: add @moduledoc to UserAuth and UserNotifier
Resolves credo readability warnings for missing module documentation.
2026-05-05 22:14:44 +01:00
mostalive 0513154b01 Fix remaining 12 multi-line conn shadowing issues
Manually refactor multi-line conn = post/put(conn, ...) patterns
across user_session, user_settings, and user_registration controller
tests. Rename shadowed conn to response using pipeline operator.

Also add plans/multi-line-conn-refactoring.md spec for future
Elixir-based tooling to handle these patterns automatically.
2026-05-05 22:03:06 +01:00
mostalive a89d09e432 fixed 11 out or 34 conn shadowing cases
11 are multi-line, script does not do that
2026-05-05 21:26:00 +01:00
mostalive 73f6ca5049 remove integration test that was too specific 2026-05-05 19:46:18 +01:00
mostalive 6e34eb6181 Add guidance on monorepo, brief and yaks 2026-05-05 18:31:54 +01:00
mostalive a5acf21395 Remopve failed plan executor 2026-05-05 15:44:01 +01:00
mostalive a181c0e814 plan to fix the issues in the readme 2026-05-05 14:44:27 +01:00
mostalive 8cc1524fec DEADEND: a work-the-plan agent that is too complicated
kept for reference -dead end
2026-05-05 14:43:44 +01:00
mostalive 7be68af01e Create README to make install easier
and add 'go' to run Andrew Nesbitt's Brief .
2026-05-05 11:15:36 +01:00
Firehose Bot 22029492d8 Add acknowledgement for Barney Dellar 2026-05-02 09:42:12 +01:00
Firehose Bot 4e0ad82681 Fix broken links 2026-05-02 09:40:11 +01:00
Firehose Bot ec16342249 publish post 2026-04-30 09:16:59 +01:00
Firehose Bot cfc26cf377 final editing on my local agentic dev setup 2026-04-30 09:10:51 +01:00
Firehose Bot 33da823668 draft: local dev setup today 2026-04-29 22:41:16 +01:00
Firehose Bot 9c769959ad Add pi.dev tag to Pi-related engineering posts 2026-04-29 21:34:00 +01:00
Firehose Bot 54344fc75e now really the spelling changes 2026-04-29 16:02:08 +01:00
Firehose Bot c02cebdd05 spelling errors 2026-04-29 16:01:29 +01:00
Firehose Bot b3011a6bd6 Contact page 2026-04-27 10:54:10 +01:00
Firehose Bot 2ec9d3ea89 Revert "show 'blog' instead of 'engineering'"
This reverts commit fe8409427a.
2026-04-27 09:21:44 +01:00
Firehose Bot fe8409427a show 'blog' instead of 'engineering' 2026-04-26 23:00:13 +01:00
Firehose Bot 9742937b99 publish 2026-04-24 15:03:50 +01:00
Firehose Bot 064b46b47c publish 2026-04-24 14:58:11 +01:00
Firehose Bot 91970d395f how to et started with Pi 2026-04-24 14:51:41 +01:00
Firehose Bot dd0ccb83b0 How to get started with Pi 2026-04-24 14:39:13 +01:00
Firehose Bot 05ab538ce5 minor edit 2026-04-24 11:59:58 +01:00
Firehose Bot 7cbac66563 typo 2026-04-24 11:58:23 +01:00
Firehose Bot b28d843db6 explain ai use 2026-04-24 11:55:00 +01:00
Firehose Bot 3fafad86db make introduction more accessible 2026-04-24 11:52:35 +01:00
Firehose Bot 4f5ec4faa0 where I am at with open models 2026-04-24 10:38:57 +01:00
Firehose Bot b7ddd8270e corrected some layout and added headings 2026-04-21 18:59:34 +01:00
Firehose Bot ea202219ba add some notes, fix some typo's 2026-04-20 18:09:56 +01:00
Firehose Bot 2ae13959e3 fix htm_l_ 2026-04-20 15:12:42 +01:00
Firehose Bot eb11558f42 fast pairing 2026-04-20 15:03:48 +01:00
Firehose Bot beb400eef2 blog post, in the flow 2026-04-19 23:17:21 +01:00
Firehose Bot 919f8be409 fix image link 2026-04-19 23:09:48 +01:00
Firehose Bot 3c592bdbc7 edited blogpost 2026-04-19 23:05:00 +01:00
Firehose Bot efc50d4ba8 as it happens - draft, needs editing 2026-04-18 10:22:21 +01:00
Firehose Bot 6eee29a9f2 writing the slow way 2026-04-18 09:20:23 +01:00
Firehose Bot a73e4f428c Afterword 2026-04-10 08:11:15 +02:00
Firehose Bot 03a653cc86 Add afterword 2026-04-10 08:10:22 +02:00
Firehose Bot 21acb8ebe6 remove editors notes, acknowledge Antony 2026-04-10 07:57:00 +02:00
Firehose Bot 58ef5b73f1 publish 2026-04-09 18:25:12 +02:00
Firehose Bot 872f3281ca add allium link 2026-04-09 18:24:51 +02:00
Firehose Bot 0bc6e77b6e more editors' notes and images 2026-04-09 18:18:15 +02:00
Firehose Bot 2bda8dd58e fix typos 2026-04-09 17:37:46 +02:00
Firehose Bot d0045507db add note about agent 2026-04-02 12:02:05 +01:00
Willem van den Ende c70d904586 automated release notes 2026-04-02 10:59:06 +00:00
Willem van den Ende dfa1763e5e I author, agents generate code 2026-04-02 09:56:49 +00:00
Willem van den Ende 9e3f44bf74 change dev server to unique (fixed) port 2026-04-02 09:56:49 +00:00
Willem van den Ende 5cc65ee391 agent forgot to commit a bunch of files 2026-04-02 09:56:49 +00:00
Willem van den Ende 806d95db6e agent forgot to commit the first allium spec 2026-04-02 09:56:49 +00:00
Firehose Bot 0a47d9d962 change description 2026-04-01 23:52:27 +01:00
Firehose Bot bcf9ce07ec make it personal 2026-04-01 23:45:15 +01:00
Firehose Bot 92f11b5478 Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-04-01 23:34:24 +01:00
Willem van den Ende b2a4cdab42 Add scheduled publishing release note blog post
Includes 7 screenshots demonstrating date filtering, status
banners, editor dashboard, and registration gating.
2026-04-01 22:10:45 +00:00
Willem van den Ende b6ff541b13 Fix status banner auth check to use current_scope
phx.gen.auth sets current_scope, not current_user. Use !! to
ensure boolean for HEEx template and register_and_log_in_user
in tests for proper auth session.
2026-04-01 22:06:07 +00:00
Willem van den Ende 88ec475a5b Sync beads: close all 12 scheduled publishing issues 2026-04-01 21:48:38 +00:00
Willem van den Ende 74a1201b88 Add integration tests for scheduled post filtering in Phoenix 2026-04-01 21:47:15 +00:00
Willem van den Ende 8d40e09e90 Verify router respects date filtering on all endpoints 2026-04-01 21:46:59 +00:00
Willem van den Ende 2591796d82 Format editor dashboard LiveView 2026-04-01 21:44:12 +00:00
Willem van den Ende f1560ff0e7 Add LiveView editor dashboard with drafts and scheduled tabs 2026-04-01 21:42:45 +00:00
Willem van den Ende 5395b2de80 Show draft/scheduled status banners for authenticated users 2026-04-01 21:40:17 +00:00
Willem van den Ende 86f7ffbe94 Gate registration to ALLOWED_REGISTRATION_EMAIL 2026-04-01 21:39:15 +00:00
Willem van den Ende df20c478f4 Seed demo user in dev environment 2026-04-01 21:36:58 +00:00
Willem van den Ende 20f12847d6 Allow direct access to draft and scheduled posts by slug 2026-04-01 20:39:19 +00:00
Willem van den Ende 370275f7b5 Verify feeds exclude future-dated posts 2026-04-01 20:37:27 +00:00
Willem van den Ende a380d0cb69 Add phx.gen.auth authentication scaffolding
- LiveView-based email/password auth via mix phx.gen.auth
- Auth links removed from public navigation (direct URL access only)
- Accounts context with User schema and token management
2026-04-01 20:31:13 +00:00
Willem van den Ende 0577ceced0 Filter future-dated posts from public views and add unfiltered post access
- all_posts/0 now excludes posts where date > today
- all_tags/0 computed at runtime from filtered posts
- posts_by_tag/1 and recent_posts/1 inherit date filtering
- Add unfiltered_posts/0 to Blog macro and FakeBlog
- Add all_posts_unfiltered/0 to Registry for dashboard use
2026-04-01 20:30:27 +00:00
Willem van den Ende 037e9f86ff Add post visibility and days_until_live helpers 2026-04-01 20:24:33 +00:00
Firehose Bot 9500c309d1 Style headings and quote 2026-03-24 19:51:08 +00:00
Firehose Bot 17a0f2709c Add current_tag attribute to post_index component 2026-03-24 14:56:54 +00:00
Firehose Bot cf7df3111f Linting rule only for dev and test
Production build broke because our custom lint rule was compiled. The
credo linter is not available and not necessary in production.

Solution: create separate directory for dev tools.
2026-03-24 14:41:58 +00:00
Firehose Bot 04a736765d not running autoresearch at the moment 2026-03-24 14:23:00 +00:00
Firehose Bot 590dd4a265 Fix trailing newline and format code 2026-03-24 14:21:22 +00:00
Firehose Bot fddbb4e777 remove sequence diagram skill, moved to other repo 2026-03-24 12:14:01 +00:00
Firehose Bot b3cdd93de8 nono sandbox 2026-03-24 12:13:05 +00:00
Firehose Bot 87e6490f85 post: blog triage with an llm 2026-03-24 12:11:23 +00:00
Firehose Bot afc763d9d9 fix score according to claude desktop 2026-03-21 18:41:21 +00:00
Firehose Bot 2708f81f1d initial setup for autoresearch of sequence diagram prompt 2026-03-21 15:39:15 +00:00
Firehose Bot 73e0d9cf1e sandboxed haiku with pi 2026-03-21 15:36:51 +00:00
Willem van den Ende 505a2d0bd6 Add custom Credo check for conn shadowing in tests
Detects `conn = get(conn, ...)` patterns and directs to
refactor_conn_aliasing.sh for automatic fixing.
2026-03-20 21:37:04 +00:00
Willem van den Ende 9426582abc Refactor conn aliasing in controller tests to use pipe chains
Applied refactor_conn_aliasing.sh to eliminate conn shadowing.

Show draft posts in test and dev
2026-03-20 21:36:08 +00:00
Willem van den Ende c18f9cd2e3 Fix Sandbox module not available in DataCase setup
The alias was inside the `using` block (only available to consumers),
but setup_sandbox/1 runs in DataCase itself. Use fully qualified name.
2026-03-20 21:19:54 +00:00
Willem van den Ende 5d49af2790 Add reusable script to refactor Phoenix test conn aliasing
Portable awk-based script that transforms conn shadowing patterns
into idiomatic pipe chains across 4 cases (body extraction, single
assert, pattern match assert, multi-use rename).
2026-03-20 21:19:54 +00:00
Firehose Bot 671add15bb fix blog tag clicks, and new post 2026-03-19 22:14:19 +00:00
Firehose Bot be4be118a3 Fix review issues from commit 2a21d75
1. Rename goto_engineering_post_page/2 to visit_engineering_path/2 for
   better accuracy (used for both post pages and tag pages)

2. Simplify Makefile test target by removing explicit ecto.create and
   ecto.migrate commands (mix test handles migrations automatically)

3. Update blog_test.exs header comment to reflect actual changes made

4. Move Sandbox alias to top level in data_case.ex
2026-03-19 11:07:17 +00:00
Firehose Bot afdf557174 adjust makefile and refactor test 2026-03-18 20:04:41 +00:00
Firehose Bot 506c72b2d8 also write something about unit tests 2026-03-18 20:03:41 +00:00
Firehose Bot 2d94bbde62 test writer skill
Focuses on integration tests, but might be more reusable
2026-03-18 20:02:20 +00:00
Firehose Bot c9901691e5 Fix blog API tests and add missing tag tests
- Add Accept: application/json headers to all API endpoint tests
- Add GET /blog/releases/tag/:tag HTML page test
- Add GET /api/blog/*/tag/:tag JSON API tests for both blogs
- Fix feed.xml assertions to check body first, then content type
2026-03-18 19:03:40 +00:00
Willem van den Ende a5c26f24ab Add healthcheck and attempt to fix devcontainer
user was root instead of vscode, and pi was broken.
Claude code had gone missing
2026-03-18 17:15:45 +00:00
mostalive 3837a72059 update blog post, and run credo with 'pi' 2026-03-18 15:03:24 +00:00
mostalive 9be7c1774b Dokku setup script did not work that well, fixed by hand 2026-03-18 14:38:45 +00:00
Willem van den Ende a5dee5c21e set default port to 5000 for production 2026-03-18 13:55:49 +00:00
Willem van den Ende 9e6252e1e7 set DATABASE_URL 2026-03-18 13:48:12 +00:00
Willem van den Ende 6f2beb8bb8 Add MIT license 2026-03-18 13:22:12 +00:00
Willem van den Ende 24847ca7fd Clearly mark sample posts as generated 2026-03-18 12:11:28 +00:00
Willem van den Ende e0e5acb322 open port 4050 for testing in docker compose file 2026-03-18 12:07:20 +00:00
Willem van den Ende 9bad5d3770 Enable UTF-8 in devcontainer 2026-03-18 11:30:27 +00:00
Willem van den Ende f563d3c26a Add postgres to devcontainer / compose 2026-03-18 11:22:54 +00:00
Willem van den Ende e780d6b6e5 Add Dockerfile-based Dokku deployment for monorepo layout
Uses a multi-stage Docker build that copies both app/ and blogex/,
preserving the path dependency. Includes release scripts, migration
module, and a sample Dokku setup script.
2026-03-18 10:55:44 +00:00
225 changed files with 37003 additions and 348 deletions
+44
View File
@@ -0,0 +1,44 @@
# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json
last-touched
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Legacy database files
db.sqlite
bd.db
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# Sync state (local-only, per-machine)
# These files are machine-specific and should not be shared across clones
.sync.lock
sync_base.jsonl
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
# They would override fork protection in .git/info/exclude, allowing
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.
+81
View File
@@ -0,0 +1,81 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --status in_progress
bd update <issue-id> --status done
# Sync with git remote
bd sync
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
**AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Intelligent JSONL merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought*
+62
View File
@@ -0,0 +1,62 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, no SQLite, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of SQLite database
# no-db: false
# Disable daemon for RPC communication (forces direct database access)
# no-daemon: false
# Disable auto-flush of database to JSONL after mutations
# no-auto-flush: false
# Disable auto-import from JSONL when it's newer than database
# no-auto-import: false
# Enable JSON output by default
# json: false
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Path to database (overridden by BEADS_DB or --db)
# db: ""
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
# auto-start-daemon: true
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
# flush-debounce: "5s"
# Git branch for beads commits (bd sync will commit to this branch)
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
# This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
# sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo
View File
+12
View File
@@ -0,0 +1,12 @@
{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:38:37.480901856Z","closed_at":"2026-04-01T20:38:37.480901856Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:39:26.605057721Z","closed_at":"2026-04-01T20:39:26.605057721Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"}
{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"}
{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:42:49.026878069Z","closed_at":"2026-04-01T21:42:49.026878069Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"}
{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.881106002Z","closed_at":"2026-04-01T21:47:19.881106002Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"}
{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.87799142Z","closed_at":"2026-04-01T21:47:19.87799142Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]}
+4
View File
@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git add:*)"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
((markdown-mode . ((qwan-markdown-attachment-dir . "/Users/willem/dev/elixir/firehose/app/priv/static/images/blog/2026"))))
+3
View File
@@ -0,0 +1,3 @@
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads
+9
View File
@@ -1,3 +1,12 @@
# Dokku setup (may contain secrets)
dokku-setup.sh
/output/
.claude/worktrees
app/priv/blog/engineering/2026/04-24-what-it-takes-to-get-started-with-the-pi-coding-agent.md
/tmp_work/
.yaks
transcripts/
.pi/skills/demo/chrome
.rodney/
.dir-locals.el
*.log
View File
+37
View File
@@ -0,0 +1,37 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
try {
const { stdout, stderr, code } = await pi.exec("make", ["test"]);
const output = stdout + stderr;
if (code === 0) {
ctx.ui.notify("make test passed", "info");
} else {
ctx.ui.notify(`make test failed (exit ${code})`, "error");
}
// Also send the output as a message so it's visible in the session
pi.sendMessage({
customType: "run-make-test",
content: [
{
type: "text",
text: `Here's the result of running \`make test\`:\n\n\`\`\`\n${output}\`\`\``,
},
],
display: true,
}, { deliverAs: "followUp" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pi.sendMessage({
customType: "run-make-test",
content: [
{ type: "text", text: `Error running make test: ${message}` },
],
display: true,
}, { deliverAs: "followUp" });
}
});
}
+68
View File
@@ -0,0 +1,68 @@
# Minimal headless Chrome container for Rodney/Showboat demos.
#
# Build:
# docker build -t demo-chrome .pi/skills/demo/
#
# Run:
# docker run -d --name demo-chrome \
# --cap-add=SYS_ADMIN --cap-drop=ALL \
# --security-opt=no-new-privileges:false \
# -p 9222:9222 \
# demo-chrome
#
# Connect: rodney connect localhost:9222
# Stop: docker stop demo-chrome && docker rm demo-chrome
#
# To use a pre-downloaded Chromium binary (from rod), copy the contents
# of ~/.cache/rod/browser/chromium-*/ into the build context first:
# cp -r ~/.cache/rod/browser/chromium-*/ .pi/skills/demo/chrome/
# docker build -t demo-chrome .pi/skills/demo/
FROM debian:bookworm-slim
# Install only the bare minimum dependencies Chrome needs
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libxfixes3 \
libx11-xcb1 \
libxcb1 \
libx11-6 \
libxext6 \
libxrender1 \
fonts-liberation \
fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
# Copy pre-downloaded Chromium binary (from rod's cache)
# This is the binary that `rodney start` downloads to ~/.cache/rod/browser/
COPY chrome/ /opt/chrome/
RUN chmod 4755 /opt/chrome/chrome_sandbox
WORKDIR /app
# Expose Chrome's debug port for Rodney
EXPOSE 9222
# Run headless with minimal flags
# --no-sandbox: needed because we run as root in container
# --disable-gpu: no GPU in container
# --disable-dev-shm-usage: avoid /dev/shm size limits
CMD ["/opt/chrome/chrome", \
"--remote-debugging-address=0.0.0.0", \
"--remote-debugging-port=9222", \
"--headless=new", \
"--no-sandbox", \
"--disable-gpu", \
"--disable-dev-shm-usage"]
+62
View File
@@ -0,0 +1,62 @@
# Demo Skill
Generate living demo documents proving features work, with showboat for Markdown assembly and rodney for browser screenshots.
## Prerequisites
Requires two Go CLI tools:
```bash
go install github.com/simonw/showboat@latest
go install github.com/simonw/rodney@latest
```
Verify installation:
```bash
showboat --help
rodney --help
```
### Rodney Chrome data directory
Rodney needs write access to the Chrome data directory at `~/.rodney`. If you get `Permission denied` errors when running `rodney start`, fix it:
```bash
mkdir -p ~/.rodney && chmod 755 ~/.rodney
```
## Usage
In pi, invoke via:
```
/skill:demo <feature-name> [--scenario <description>] [--plan <path>]
```
Or ask naturally:
- "demo this"
- "show me it works"
- "create a demo"
- "create a demo of the authentication flow"
## How It Works
1. Checks that showboat and rodney are installed
2. Gathers feature context from plan files, recent commits, or your description
3. Verifies the dev server is running
4. Handles authentication (creates a demo user if needed)
5. Captures backend evidence (tests, compilation, database state)
6. Takes browser screenshots of UI pages
7. Maps evidence to acceptance criteria
8. Produces a standalone Markdown demo document in `demos/`
## Demo Document Structure
- **Feature Overview** — narrative description
- **Test Suite** — relevant test output
- **Compilation Check** — `mix compile --warnings-as-errors`
- **Database State** — if relevant
- **UI Screenshots** — static pages and interactive flows
- **Acceptance Criteria Verification** — checklist with evidence references
+202
View File
@@ -0,0 +1,202 @@
---
name: demo
description: >-
Generate a living demo document proving a feature works. Uses showboat for
Markdown assembly with captured command output and rodney for Chrome browser
screenshots. Use when the user says "demo this", "show me it works", "create
a demo", or after /build completes.
allowed-tools: Read Write Glob Grep Bash
---
# Demo Skill
Role: worker. This command generates a standalone Markdown demo document that
proves a feature works, using showboat for document assembly and rodney for
browser automation.
You have been invoked with the /demo command.
Parse Arguments
Arguments: $ARGUMENTS
Positional: <feature-name> (required) - short name or description of the feature to demo
--scenario <description>: Explicit demo scenario describing what to show. If omitted, infer from the plan and recent commits.
--plan <path>: Path to the plan file. If omitted, search plans/ for the most recently modified .md file with status implemented or approved.
Steps
1. Check tool availability
Verify showboat and rodney are installed:
showboat --help 2>/dev/null && echo "showboat: ok" || echo "showboat: missing"
rodney --help 2>/dev/null && echo "rodney: ok" || echo "rodney: missing"
If either tool is missing, tell the user:
One or more demo tools are missing. Install them with:
go install github.com/simonw/showboat@latest
go install github.com/simonw/rodney@latest
Do not proceed until both tools are confirmed available.
2. Gather feature context
Build an understanding of what to demo from these sources (in priority order):
Explicit scenario (--scenario): If provided, use as primary guide.
Plan file: Read the plan's Goal, Acceptance Criteria, and completed Steps.
Recent commits: Run git log --oneline -15 and git diff main...HEAD --stat to identify changed files and commit messages.
Route map: Cross-reference changed files against known LiveView routes in the router (lib/hub_web/router.ex).
From these sources, produce a demo outline:
Narrative: 2-3 sentence description of what the feature does
Backend evidence: mix commands, test output, or database queries to run
UI pages: which routes to visit and what to look for
Interactions: any clicks, form fills, or navigation sequences to perform
If no plan exists and commits are ambiguous, ask the user for a brief description of what to demo.
3. Check dev server
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ 2>/dev/null
If the server is not reachable:
The Phoenix dev server is not running. Start it now?
mix phx.server &
After starting, wait up to 10 seconds and verify connectivity. If it still fails, proceed with backend-only evidence (skip all browser screenshots) and note the limitation in the demo document.
4. Handle authentication
The app requires authentication for LiveView routes. Before capturing UI screenshots:
Ensure a demo user exists:
mix run -e "
alias Hub.Accounts
case Accounts.get_user_by_email(\"demo@example.com\") do
nil -> Accounts.register_user(%{email: \"demo@example.com\", password: \"demodemo1234\"})
user -> {:ok, user}
end
"
Log in via rodney:
rodney start
rodney open http://localhost:4000/users/log-in
rodney wait "input[name='user[email]']"
rodney input "input[name='user[email]']" "demo@example.com"
rodney input "input[name='user[password]']" "demodemo1234"
rodney click "button[type='submit']"
rodney waitidle
If login fails, warn and proceed with backend-only evidence.
5. Initialize the demo document
Slugify the feature name (lowercase, hyphens, no special chars). Then:
showboat init "demos/demo-$(date +%Y%m%d-%H%M%S)-<slug>.md" "Demo: <Feature Name>"
Store the demo file path for use in all subsequent steps.
6. Narrative introduction
showboat note <demo-file> "## Feature Overview
<2-3 sentence description derived from the plan or commits.>
**Branch**: $(git branch --show-current)
**Commits**: <N> commits ahead of main
**Plan**: <plan file path or 'none'>
"
7. Backend evidence
Capture backend proof via showboat exec. Always include relevant tests. Add narrative notes between evidence blocks explaining what each proves.
Test output (always include):
showboat note <demo-file> "## Test Suite"
showboat exec <demo-file> bash "mix test <relevant-test-files> --color"
showboat note <demo-file> "All <N> tests pass, confirming <specific criterion>."
Compilation check:
showboat note <demo-file> "## Compilation Check"
showboat exec <demo-file> bash "mix compile --warnings-as-errors"
Database state (if relevant to the feature):
showboat note <demo-file> "## Database State"
showboat exec <demo-file> bash "mix run -e '<query expression>'"
8. UI screenshots
For each UI page identified in step 2, navigate with rodney, screenshot, and embed via showboat.
Static page capture:
rodney open http://localhost:4000/<route>
rodney waitidle
rodney screenshot demos/screenshots/<feature>-<page-name>.png
showboat note <demo-file> "### <Page Name>
<What this page shows and why it proves the feature works.>"
showboat image <demo-file> '![<What the screenshot demonstrates>](demos/screenshots/<feature>-<page-name>.png)'
Interactive flow (form submissions, navigation):
showboat note <demo-file> "### Interactive Flow: <Flow Name>"
# Before state
rodney screenshot demos/screenshots/<feature>-before.png
showboat image <demo-file> '![Before: <initial state>](demos/screenshots/<feature>-before.png)'
# Perform interaction
rodney click "<selector>"
rodney input "<selector>" "<value>"
rodney click "<submit-selector>"
rodney waitidle
# After state
rodney screenshot demos/screenshots/<feature>-after.png
showboat image <demo-file> '![After: <resulting state>](demos/screenshots/<feature>-after.png)'
9. Acceptance criteria checklist
If a plan file exists, map each acceptance criterion to evidence:
showboat note <demo-file> "## Acceptance Criteria Verification
- [x] <Criterion 1> -- see Test Suite output above
- [x] <Criterion 2> -- see <Page Name> screenshot
- [x] <Criterion 3> -- see Database State output
"
If no plan, summarize what was demonstrated and what it proves.
10. Clean up
rodney stop 2>/dev/null || true
11. Report results
Display:
## Demo Complete
- **Document**: demos/<filename>.md
- **Screenshots**: <N> captured in demos/screenshots/
- **Evidence**: <N> backend commands, <N> UI screenshots
- **Acceptance criteria**: <N>/<M> demonstrated
Error Handling
Tools not installed: Show go install commands. Do not proceed without them.
Dev server not running: Offer to start. If startup fails, produce backend-only demo and note the limitation.
Authentication failure: Proceed with backend-only evidence. Note skipped UI screenshots in the document.
Screenshot failure: Log the error as a note in the demo document, continue with remaining screenshots.
No plan found: Infer from git commits and changed files. Ask the user for a description if commits are ambiguous.
Rodney/Chrome crash: Run rodney stop then rodney start to reset. Retry once. If it fails again, degrade to backend-only.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
# init.sh — Set up the demo skill's headless Chrome container
#
# Prerequisites:
# - go (for installing showboat & rodney)
# - docker
#
# This script:
# 1. Installs showboat and rodney if missing
# 2. Downloads Chromium for the host (macOS, via rodney)
# 3. Downloads Chromium for the Docker image (Linux_x64, from GCS)
# 4. Copies Linux binary into chrome/ (the Docker build context)
# 5. Builds the demo-chrome image
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CHROME_DIR="$SKILL_DIR/chrome"
IMAGE_NAME="${1:-demo-chrome}"
echo "==> Checking prerequisites..."
# ------------------------------------------------------------------
# 1. Install CLI tools
# ------------------------------------------------------------------
GO_BIN_DIR="$(go env GOBIN 2>/dev/null || echo "$HOME/go/bin")"
export PATH="$GO_BIN_DIR:$PATH"
if ! command -v showboat &>/dev/null; then
echo "==> Installing showboat..."
go install github.com/simonw/showboat@latest
else
echo " showboat: ok"
fi
if ! command -v rodney &>/dev/null; then
echo "==> Installing rodney..."
go install github.com/simonw/rodney@latest
else
echo " rodney: ok"
fi
# ------------------------------------------------------------------
# 2. Download macOS Chromium for local rodney
# ------------------------------------------------------------------
echo "==> Downloading macOS Chromium via rodney..."
rodney start 2>/dev/null || true
rodney stop 2>/dev/null || true
ROD_CACHE="$HOME/.cache/rod/browser"
if [ -d "$ROD_CACHE" ]; then
CHROMIUM_DIR=$(ls -d "$ROD_CACHE"/chromium-* 2>/dev/null | head -1)
else
echo "ERROR: rod cache not found at $ROD_CACHE"
exit 1
fi
if [ -z "$CHROMIUM_DIR" ]; then
echo "ERROR: No chromium-* directory found in $ROD_CACHE"
exit 1
fi
echo " macOS Chromium at: $CHROMIUM_DIR"
# Extract revision number from directory name (e.g. chromium-1321438)
REVISION=$(basename "$CHROMIUM_DIR" | sed 's/chromium-//')
echo " Revision: $REVISION"
# ------------------------------------------------------------------
# 3. Download Linux Chromium for the Docker image
# ------------------------------------------------------------------
LINUX_ZIP="/tmp/chrome-linux-$REVISION.zip"
LINUX_EXTRACT="/tmp/chrome-linux-extract"
echo "==> Downloading Linux_x64 Chromium (revision $REVISION)..."
if [ ! -f "$LINUX_ZIP" ]; then
curl -fSL \
"https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/$REVISION/chrome-linux.zip" \
-o "$LINUX_ZIP"
echo " Downloaded: $LINUX_ZIP"
else
echo " Already cached: $LINUX_ZIP"
fi
# ------------------------------------------------------------------
# 4. Extract and copy into build context
# ------------------------------------------------------------------
echo "==> Extracting Linux Chromium into $CHROME_DIR ..."
rm -rf "$LINUX_EXTRACT" "$CHROME_DIR"
mkdir -p "$LINUX_EXTRACT"
unzip -q "$LINUX_ZIP" -d "$LINUX_EXTRACT"
# chrome-linux.zip contains a single chrome-linux/ directory
mv "$LINUX_EXTRACT/chrome-linux" "$CHROME_DIR"
# IMPORTANT: set suid on the sandbox binary so Chrome can sandbox child processes
if [ -f "$CHROME_DIR/chrome_sandbox" ]; then
chmod 4755 "$CHROME_DIR/chrome_sandbox"
fi
echo "==> Files in $CHROME_DIR:"
ls -lh "$CHROME_DIR/" | head -15
# Clean up extraction temp
rm -rf "$LINUX_EXTRACT"
# ------------------------------------------------------------------
# 5. Build Docker image
# ------------------------------------------------------------------
echo "==> Building Docker image '$IMAGE_NAME' ..."
docker build --tag "$IMAGE_NAME" "$SKILL_DIR"
# ------------------------------------------------------------------
# 6. Verify
# ------------------------------------------------------------------
echo ""
echo "==> Success!"
echo " Image: $IMAGE_NAME"
echo ""
echo " Run the container:"
echo " docker run -d --name demo-chrome \\"
echo " --cap-add=SYS_ADMIN --cap-drop=ALL \\"
echo " --security-opt=no-new-privileges:false \\"
echo " -p 9222:9222 \\"
echo " $IMAGE_NAME"
echo ""
echo " Connect rodney to it:"
echo " rodney connect localhost:9222"
echo ""
echo " Stop the container:"
echo " docker stop demo-chrome && docker rm demo-chrome"
+69
View File
@@ -0,0 +1,69 @@
# Agent Instructions
This project has a *zero defects policy*.
When you are unsure about something, ALWAYS ask the user.
# Communication
When creating yaks or working on features, always ask clarifying questions to ensure understanding. Ask questions one at a time to maintain clear communication and allow for focused discussion.
You run in a `nono` sandbox. Always ask the user to run commands that require privilege, like docker, sudo or package installation.
# Repository structure
This is an Elixir monorepo with two parts:
```
firehose/
├── app/ # Phoenix application (OTP app: :firehose)
│ ├── lib/firehose/ # Application logic
│ ├── lib/firehose_web/ # Web layer (controllers, live views, components)
│ ├── lib/firehose/blogs/ # Blog definitions (engineering, release notes)
│ ├── priv/blog/ # Markdown posts
│ └── mix.exs
├── blogex/ # Blogex library (multi-blog engine)
│ ├── lib/
│ └── mix.exs
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
└── Makefile # make (test, check)
```
Two blogs are configured:
| Blog | Route | Description |
|------|-------|-------------|
| Engineering | `/blog/engineering` | Main blog |
| Release Notes | `/blog/releases` | What's new in an app |
# Permissions
ALWAYS ask the user for permission when you want to add a fallback, or are unsure something work. The user can perform exploratory testing.
We have a zero defects policy, so never continue when there are errors, warnings or test failures, even when they are pre-existing. ALWAYS report these to the user and discuss an action plan with root cause analysis. (see TDD below).
# Planning
we use `yx` for planning run yx --help to see options. Invoke when the user wants to make a plan, work on something for a plan or mentions 'yak' or 'yaks'.
`yx list` - shows open yaks
`yx add` - adds yaks
`yx context "yak name"` - adds human-readable description (pipe via stdin)
`yx show "yak name"` - shows yak details including context
# Building
This is a Phoenix Liveview monorepo, the blogging library is in blogex, the application is in 'app'. Use `make` to build and `brief` to find out more about mix.
Main make targets:
- make test
- make check - runs credo static analysis and suggests refactorings
# Development
Always fix failing tests, credo issues and format isseus.
When developing new features, we apply TDD. Work Test-First, and Refactor when all tests are passing.
If you believe an issue is pre-existing, stop work, and have a conversation with the user on how to address the pre-existing issue, as well as how to prevent this in the future.
+4 -1
View File
@@ -13,7 +13,7 @@ ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
# =============================================================================
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
RUN apt-get update -y && apt-get install -y build-essential git nodejs npm \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /build
@@ -47,6 +47,9 @@ COPY app/config/runtime.exs config/
RUN mix compile
# Install npm dependencies for JS assets
RUN cd assets && npm install
# Build assets after compile (phoenix-colocated hooks need compiled app)
RUN mix assets.deploy
+29
View File
@@ -25,3 +25,32 @@ test:
# Format code
format:
@make -C app format
# Serve the app
serve:
@cd app && mix phx.server
# Run Playwright browser-based tests (requires running server)
# Set PLAYWRIGHT_BASE_URL to point at the running Phoenix app
playwright:
@make -C app playwright
# Run Playwright in UI mode (headed browser)
playwright-ui:
@make -C app playwright-ui
# Run Playwright against Docker Chrome container (must be running)
playwright-docker:
@make -C app playwright-docker
# Full e2e workflow: start Chrome container, run tests, stop container
playwright-full:
@make -C app playwright-full
# Start Docker Chrome container
playwright-docker-start:
@make -C app playwright-docker-start
# Stop Docker Chrome container
playwright-docker-stop:
@make -C app playwright-docker-stop
+149
View File
@@ -0,0 +1,149 @@
# Firehose
Willem's personal blog — a modular static blog built on [Blogex](./blogex/), powered by Phoenix and NimblePublisher.
Posts live as markdown files in git and compile into the BEAM at build time: zero database, zero runtime I/O, sub-millisecond reads.
The project structure is modular, I wanted to have a personal blog, and an easy way to add an 'engineering' blog to fledgling SaaS products, as well as release notes. The release notes now also serve to announce new features on my blog.
Status: Experimental. Works on my server ;-).
Enjoy, [Willem van den Ende](https://willemvandenende.com)
## Project structure
This is a monorepo with two parts:
```
firehose/
├── app/ # Phoenix application (OTP app: :firehose)
│ ├── lib/firehose/ # Application logic
│ ├── lib/firehose_web/ # Web layer (controllers, live views, components)
│ ├── lib/firehose/blogs/ # Blog definitions (engineering, release notes)
│ ├── priv/blog/ # Markdown posts
│ └── mix.exs
├── blogex/ # Blogex library (multi-blog engine)
│ ├── lib/
│ └── mix.exs
└── mise.toml # Runtime versions (Elixir, Erlang, Node)
```
Two blogs are configured:
| Blog | Route | Description |
|------|-------|-------------|
| Engineering | `/blog/engineering` | Main blog |
| Release Notes | `/blog/releases` | What's new in an app |
## Prerequisites
- **Elixir** 1.15+ and **Erlang/OTP 26+** (managed by [mise](https://mise.jdx.dev))
- **Node.js** (managed by mise, for Tailwind + esbuild assets)
- **PostgreSQL** 16+ running on `localhost:5432`
## Setup
### 1. Install runtimes
```bash
mise install
```
This installs Elixir, Erlang, and Node at the versions specified in `mise.toml`.
### 2. Start PostgreSQL
You need PostgreSQL running locally with a `postgres` user (password: `postgres`).
**Option A — Docker (quick):**
```bash
docker run -d --name firehose-pg -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
```
**Option B — Dev container:**
```bash
cd .devcontainer && docker compose up -d db
```
**Option C — System install:**
Install PostgreSQL normally and ensure a `postgres` user with password `postgres` exists.
### 3. Install dependencies and set up the database
```bash
cd app
mix setup
```
This runs:
- `mix deps.get` — fetch Hex and git dependencies
- `mix ecto.create` — create the `firehose_dev` database
- `mix ecto.migrate` — run database migrations
- `mix run priv/repo/seeds.exs` — seed initial data
- `mix assets.setup` — install Tailwind and esbuild
- `mix assets.build` — compile CSS and JS assets
### 4. Start the server
```bash
mix phx.server
```
Visit [localhost:8056](http://localhost:8056) in your browser.
> **Note:** The dev server runs on port `8056` by default. Override with `PORT=4000 mix phx.server`.
## Development commands
All commands below are run from the `app/` directory.
| Command | Description |
|---------|-------------|
| `mix phx.server` | Start the dev server with live reload |
| `iex -S mix` | Start an IEx session with the app |
| `mix test` | Run the test suite |
| `mix precommit` | Run all quality gates (compile, credo, format, test) |
| `mix format` | Format Elixir/HEEx code |
| `mix credo --strict` | Run Credo static analysis |
## Writing blog posts
Posts are markdown files with Elixir frontmatter. Place them under `app/priv/blog/<blog-name>/`:
```
app/priv/blog/
├── engineering/
│ └── 2026/
│ └── 05-01-hello-world.md
└── release-notes/
└── 2026/
└── 05-01-v0-1.md
```
Frontmatter format:
```markdown
%{
title: "Hello World",
author: "Willem van den Ende",
tags: ~w(elixir phoenix blogex),
description: "My first blog post"
}
---
This is my first post. It compiles into the BEAM at build time.
```
In dev mode, edited `.md` files trigger live reload automatically.
## Blogex
The [Blogex](./blogex/) library is a multi-blog engine for Phoenix apps, powered by [NimblePublisher](https://github.com/dashbitco/nimble_publisher). See [blogex/README.md](./blogex/README.md) for full documentation.
## License
MIT — see [LICENSE](./LICENSE)
+31
View File
@@ -44,6 +44,37 @@ custom classes must fully style the input
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- phoenix-gen-auth-start -->
## Authentication
- **Always** handle authentication flow at the router level with proper redirects
- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs:
- A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
- A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
- In both cases, a `@current_scope` is assigned to the Plug connection
- A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY**
- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates
- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below**
### Routes that require authentication
Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", MyControllerThatRequiresAuth, :index
end
### Routes that work with or without authentication
Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
<!-- phoenix-gen-auth-end -->
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
+65 -1
View File
@@ -1,6 +1,6 @@
# Makefile for Firehose app
MISE_BIN ?= /home/vscode/.local/bin/mise
MISE_BIN ?= $(HOME)/.local/bin/mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: check precommit deps compile test format credo
@@ -28,6 +28,70 @@ test: deps compile
format:
$(MISE_EXEC) mix format
# =============================================================================
# Playwright e2e browser tests
# =============================================================================
#
# Requires the Phoenix dev server running on the default port (8056).
# Set PLAYWRIGHT_BASE_URL to point at a different server/port.
#
# Available browsers (local):
# PLAYWRIGHT_BROWSER=firefox (default — works on macOS 15)
# PLAYWRIGHT_BROWSER=chromium (crashes on macOS 15, use Docker Chrome)
# PLAYWRIGHT_BROWSER=webkit
#
# Docker Chrome (alternative):
# 1. docker compose -f docker-compose.chrome.yml up -d
# 2. PLAYWRIGHT_BROWSERS_PATH=assets/node_modules/playwright-core/.local-browsers \
# npx playwright test --config=playwright.docker.config.ts
#
# =============================================================================
# Run Playwright e2e tests (default: local Firefox)
# Usage: make playwright
# PLAYWRIGHT_BROWSER=chromium make playwright
playwright:
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts
# Run a specific Playwright test file
# Usage: make playwright-test test/e2e/smoke.spec.ts
playwright-test:
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts $(filter-out $@,$(MAKECMDGOALS))
# Run Playwright tests with UI mode (headed browser)
playwright-ui:
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts --ui
# Show last Playwright HTML report
playwright-report:
cd assets && npx playwright show-report
# Start Docker Chrome container for remote Playwright
playwright-docker-start:
docker compose -f docker-compose.chrome.yml up -d
# Stop Docker Chrome container
playwright-docker-stop:
docker compose -f docker-compose.chrome.yml down
# Run Playwright against Docker Chrome (container must be running)
playwright-docker:
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts
# Run full end-to-end workflow: start Docker Chrome, run tests, stop container
playwright-full: playwright-docker-start
@echo "Waiting for Chrome to be ready..."
@sleep 3
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts; \
EXIT_CODE=$$?; \
cd ..; \
docker compose -f docker-compose.chrome.yml down; \
exit $$EXIT_CODE
# =============================================================================
# Static analysis
# =============================================================================
# Run Credo static analysis
credo:
$(MISE_EXEC) mix credo --strict
+175 -2
View File
@@ -187,14 +187,16 @@ body { font-family: 'Source Sans 3', sans-serif; }
line-height: 1.75;
}
.blogex-post-body h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; }
.blogex-post-body h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.blogex-post-body h1 { font-family: 'Fraunces', serif; font-size: 1.75rem; font-weight: 700; margin-top: 2.5rem; margin-bottom: 0.5rem; }
.blogex-post-body h2 { font-family: 'Fraunces', serif; font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; }
.blogex-post-body h3 { font-family: 'Fraunces', serif; font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.blogex-post-body p { margin-top: 1rem; }
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body code { font-size: 0.875em; }
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
.blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
.blogex-tag-list {
display: flex;
@@ -233,3 +235,174 @@ body { font-family: 'Source Sans 3', sans-serif; }
.blogex-pagination a:hover {
text-decoration: underline;
}
/* Source Viewer — line-numbered code display */
.source-viewer {
--sv-bg: oklch(20.15% 0.012 254.09);
--sv-text: oklch(90% 0.01 240);
--sv-line-num: oklch(55% 0.02 240);
--sv-highlight: oklch(30% 0.02 240);
}
[data-theme="dark"] .source-viewer {
--sv-bg: oklch(16% 0.01 254);
--sv-text: oklch(88% 0.01 240);
--sv-line-num: oklch(50% 0.02 240);
--sv-highlight: oklch(25% 0.02 240);
}
.sv-lines {
display: flex;
flex-direction: column;
}
.sv-line {
display: flex;
align-items: baseline;
min-height: 1.25rem;
padding: 0 0.25rem;
transition: background-color 0.2s ease;
}
.sv-line-highlighted {
background-color: var(--sv-highlight);
}
.sv-line-number {
display: inline-block;
width: 2.5rem;
text-align: right;
padding-right: 0.75rem;
color: var(--sv-line-num);
user-select: none;
opacity: 0.6;
flex-shrink: 0;
}
.sv-line-content {
white-space: pre;
flex: 1;
min-width: 0;
}
/* highlight.js — Atom One Dark (dark theme) */
.source-viewer .hljs {
color: #abb2bf;
background: transparent;
}
.source-viewer .hljs .hljs-comment,
.source-viewer .hljs .hljs-quote {
color: #5c6370;
font-style: italic;
}
.source-viewer .hljs .hljs-doctag,
.source-viewer .hljs .hljs-keyword,
.source-viewer .hljs .hljs-formula {
color: #c678dd;
}
.source-viewer .hljs .hljs-section,
.source-viewer .hljs .hljs-name,
.source-viewer .hljs .hljs-selector-tag,
.source-viewer .hljs .hljs-deletion,
.source-viewer .hljs .hljs-subst {
color: #e06c75;
}
.source-viewer .hljs .hljs-literal {
color: #56b6c2;
}
.source-viewer .hljs .hljs-string,
.source-viewer .hljs .hljs-regexp,
.source-viewer .hljs .hljs-addition,
.source-viewer .hljs .hljs-attribute,
.source-viewer .hljs .hljs-meta .hljs-string {
color: #98c379;
}
.source-viewer .hljs .hljs-attr,
.source-viewer .hljs .hljs-variable,
.source-viewer .hljs .hljs-template-variable,
.source-viewer .hljs .hljs-type,
.source-viewer .hljs .hljs-selector-class,
.source-viewer .hljs .hljs-selector-attr,
.source-viewer .hljs .hljs-selector-pseudo,
.source-viewer .hljs .hljs-number {
color: #d19a66;
}
.source-viewer .hljs .hljs-symbol,
.source-viewer .hljs .hljs-link,
.source-viewer .hljs .hljs-meta,
.source-viewer .hljs .hljs-selector-id,
.source-viewer .hljs .hljs-title {
color: #61aeee;
}
.source-viewer .hljs .hljs-built_in,
.source-viewer .hljs .hljs-title.class_,
.source-viewer .hljs .hljs-class .hljs-title {
color: #e6c07b;
}
.source-viewer .hljs .hljs-emphasis {
font-style: italic;
}
.source-viewer .hljs .hljs-strong {
font-weight: bold;
}
/* highlight.js — Atom One Light (light theme) */
[data-theme="light"] .source-viewer .hljs {
color: #383a42;
background: transparent;
}
[data-theme="light"] .source-viewer .hljs .hljs-comment,
[data-theme="light"] .source-viewer .hljs .hljs-quote {
color: #a0a1a7;
font-style: italic;
}
[data-theme="light"] .source-viewer .hljs .hljs-doctag,
[data-theme="light"] .source-viewer .hljs .hljs-keyword,
[data-theme="light"] .source-viewer .hljs .hljs-formula {
color: #a626a4;
}
[data-theme="light"] .source-viewer .hljs .hljs-section,
[data-theme="light"] .source-viewer .hljs .hljs-name,
[data-theme="light"] .source-viewer .hljs .hljs-selector-tag,
[data-theme="light"] .source-viewer .hljs .hljs-deletion,
[data-theme="light"] .source-viewer .hljs .hljs-subst {
color: #e45649;
}
[data-theme="light"] .source-viewer .hljs .hljs-literal {
color: #0184bc;
}
[data-theme="light"] .source-viewer .hljs .hljs-string,
[data-theme="light"] .source-viewer .hljs .hljs-regexp,
[data-theme="light"] .source-viewer .hljs .hljs-addition,
[data-theme="light"] .source-viewer .hljs .hljs-attribute,
[data-theme="light"] .source-viewer .hljs .hljs-meta .hljs-string {
color: #50a14f;
}
[data-theme="light"] .source-viewer .hljs .hljs-attr,
[data-theme="light"] .source-viewer .hljs .hljs-variable,
[data-theme="light"] .source-viewer .hljs .hljs-template-variable,
[data-theme="light"] .source-viewer .hljs .hljs-type,
[data-theme="light"] .source-viewer .hljs .hljs-selector-class,
[data-theme="light"] .source-viewer .hljs .hljs-selector-attr,
[data-theme="light"] .source-viewer .hljs .hljs-selector-pseudo,
[data-theme="light"] .source-viewer .hljs .hljs-number {
color: #986801;
}
[data-theme="light"] .source-viewer .hljs .hljs-symbol,
[data-theme="light"] .source-viewer .hljs .hljs-link,
[data-theme="light"] .source-viewer .hljs .hljs-meta,
[data-theme="light"] .source-viewer .hljs .hljs-selector-id,
[data-theme="light"] .source-viewer .hljs .hljs-title {
color: #4078f2;
}
[data-theme="light"] .source-viewer .hljs .hljs-built_in,
[data-theme="light"] .source-viewer .hljs .hljs-title.class_,
[data-theme="light"] .source-viewer .hljs .hljs-class .hljs-title {
color: #c18401;
}
[data-theme="light"] .source-viewer .hljs .hljs-emphasis {
font-style: italic;
}
[data-theme="light"] .source-viewer .hljs .hljs-strong {
font-weight: bold;
}
+22 -1
View File
@@ -23,13 +23,34 @@ import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/firehose"
import {SourceViewer} from "./hooks/source_viewer"
import topbar from "../vendor/topbar"
// Syntax highlighting via highlight.js
import hljs from "highlight.js/lib/core"
import elixir from "highlight.js/lib/languages/elixir"
import javascript from "highlight.js/lib/languages/javascript"
import typescript from "highlight.js/lib/languages/typescript"
import markdown from "highlight.js/lib/languages/markdown"
import python from "highlight.js/lib/languages/python"
import ruby from "highlight.js/lib/languages/ruby"
import bash from "highlight.js/lib/languages/bash"
import sql from "highlight.js/lib/languages/sql"
hljs.registerLanguage("elixir", elixir)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("typescript", typescript)
hljs.registerLanguage("markdown", markdown)
hljs.registerLanguage("python", python)
hljs.registerLanguage("ruby", ruby)
hljs.registerLanguage("bash", bash)
hljs.registerLanguage("sql", sql)
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
hooks: {...colocatedHooks, SourceViewer},
})
// Show progress bar on live navigation and form submits
+61
View File
@@ -0,0 +1,61 @@
/**
* SourceViewer hook — scrolls to and highlights the selected line.
*
* Attached to the `#source-viewer` div via `phx-hook="SourceViewer"`.
* Reads `data-highlighted-line` to find the target line element and
* scrolls it into view with a smooth animation.
* Applies highlight.js syntax highlighting to individual line content elements.
*/
import hljs from "highlight.js/lib/core"
export const SourceViewer = {
mounted() {
this.applySyntaxHighlighting()
this.scrollToHighlighted()
},
updated() {
this.applySyntaxHighlighting()
this.scrollToHighlighted()
},
applySyntaxHighlighting() {
const container = this.el.querySelector(".sv-lines")
if (!container || container.querySelector(".hljs-on")) return;
// Collect the raw text from all line content spans
const lineContentEls = container.querySelectorAll(".sv-line-content")
if (lineContentEls.length === 0) return;
const rawText = Array.from(lineContentEls).map(el => el.textContent).join("\n")
// Create a temp element for highlight.js to process
const temp = document.createElement("code")
temp.textContent = rawText
hljs.highlightElement(temp)
// Split the highlighted HTML back into lines
// highlight.js innerHTML uses actual newlines between lines of code
const highlightedLines = temp.innerHTML.split("\n")
// Apply each highlighted line to the corresponding .sv-line-content
lineContentEls.forEach((el, i) => {
if (highlightedLines[i]) {
el.innerHTML = highlightedLines[i]
}
})
// Mark as highlighted so we don't re-apply on subsequent updates
container.classList.add("hljs", "hljs-on")
},
scrollToHighlighted() {
const lineNum = this.el.dataset.highlightedLine
if (lineNum !== undefined && lineNum !== "") {
const lineEl = document.getElementById(`line-${lineNum}`)
if (lineEl) {
lineEl.scrollIntoView({ behavior: "smooth", block: "center" })
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"name": "assets",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assets",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"highlight.js": "^11.11.1",
"playwright": "^1.61.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "assets",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:e2e": "playwright test --config=../playwright.config.ts",
"test:e2e:ui": "playwright test --config=../playwright.config.ts --ui"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@playwright/test": "^1.60.0"
},
"dependencies": {
"highlight.js": "^11.11.1",
"playwright": "^1.61.1"
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# Full e2e workflow: start Docker Chrome, run Playwright tests, stop container.
#
# Usage: ./bin/e2e-full.sh [extra playwright args...]
#
# Requires:
# - Docker (colima or Docker Desktop running)
# - Phoenix dev server running (default http://localhost:8056)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
echo "=== Starting Chrome container ==="
docker compose -f docker-compose.chrome.yml up -d
echo "=== Waiting for Chrome to be ready... ==="
for i in $(seq 1 30); do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ 2>/dev/null | grep -q 200; then
echo "Chrome is ready!"
break
fi
if [ "$i" -eq 30 ]; then
echo "Timed out waiting for Chrome."
docker compose -f docker-compose.chrome.yml logs --tail=20
exit 1
fi
sleep 1
done
echo "=== Running Playwright e2e tests against Docker Chrome ==="
cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers \
npx playwright test --config=../playwright.docker.config.ts "$@"
EXIT_CODE=$?
cd "$PROJECT_DIR"
echo "=== Stopping Chrome container ==="
docker compose -f docker-compose.chrome.yml down
exit $EXIT_CODE
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Helper script to start the Playwright Chrome container via Docker
# Usage: ./bin/e2e-start.sh
#
# Starts a browserless/chrome container that Playwright connects to remotely.
# The container runs Chrome with DevTools Protocol on port 9222.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "Starting Chrome container for Playwright e2e tests..."
echo "Using docker-compose.chrome.yml"
cd "$PROJECT_DIR"
# Check if colima is needed (no Docker Desktop)
if ! docker info >/dev/null 2>&1; then
echo "Docker daemon not accessible. Have you started colima or Docker Desktop?"
echo " colima start"
exit 1
fi
docker compose -f docker-compose.chrome.yml up -d
echo ""
echo "Chrome container started! Check status:"
echo " docker compose -f docker-compose.chrome.yml ps"
echo ""
echo "Run tests:"
echo " make playwright"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# Helper script to stop the Playwright Chrome container
# Usage: ./bin/e2e-stop.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
echo "Stopping Chrome container..."
docker compose -f docker-compose.chrome.yml down
echo "Chrome container stopped."
+13
View File
@@ -7,6 +7,19 @@
# General application configuration
import Config
config :firehose, :scopes,
user: [
default: true,
module: Firehose.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Firehose.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
config :firehose,
ecto_repos: [Firehose.Repo],
generators: [timestamp_type: :utc_datetime]
+1 -1
View File
@@ -19,7 +19,7 @@ config :firehose, Firehose.Repo,
config :firehose, FirehoseWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4050")],
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "8056")],
check_origin: false,
code_reloader: true,
debug_errors: true,
+13
View File
@@ -20,6 +20,8 @@ if System.get_env("PHX_SERVER") do
config :firehose, FirehoseWeb.Endpoint, server: true
end
config :firehose, :allowed_registration_email, System.get_env("ALLOWED_REGISTRATION_EMAIL")
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
@@ -116,4 +118,15 @@ if config_env() == :prod do
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
config :firehose, Firehose.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_HOST"),
port: String.to_integer(System.get_env("SMTP_PORT") || "587"),
username: System.get_env("SMTP_USERNAME"),
password: System.get_env("SMTP_PASSWORD"),
ssl: System.get_env("SMTP_SSL") in ~w(true 1),
tls: System.get_env("SMTP_TLS") in ~w(true 1) || System.get_env("SMTP_SSL") not in ~w(true 1),
retries: 2,
no_dns_lookup: true
end
+5
View File
@@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
@@ -35,3 +38,5 @@ config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
config :firehose, :allowed_registration_email, nil
@@ -0,0 +1,24 @@
# Demo: Scheduled Publishing & Author Dashboard
*2026-04-01T22:01:07Z by Showboat dev*
<!-- showboat-id: c7092b02-210b-4df7-b607-34be1027e082 -->
## Feature Overview
Scheduled publishing adds date-based post filtering to Blogex so future-dated posts are hidden from public views and feeds, while remaining accessible by direct URL for author previews. Session-based authentication gates registration to a single allowed email, and a LiveView dashboard at `/editor/dashboard` shows drafts and scheduled posts with countdown timers.
**Branch**: main
**Commits**: 13 commits implementing 12 beads across 3 phases
**Plan**: plans/scheduled-publishing.md
## Test Suite
### Blogex library (89 tests)
```bash
cd blogex && mix test --color 2>&1 | grep -E '(Finished|failures)'
```
```output
bash: line 1: cd: blogex: No such file or directory
```
+27
View File
@@ -0,0 +1,27 @@
# Docker Compose for Playwright Chrome container
#
# Usage:
# docker compose -f docker-compose.chrome.yml up -d # start Chrome
# docker compose -f docker-compose.chrome.yml down # stop Chrome
# make playwright # run tests against it
#
# The container exposes Chrome DevTools Protocol on port 9222.
# Playwright connects via the websocket endpoint.
services:
chrome:
# browserless/chrome is self-contained with Chrome + CDP exposed
# multi-arch (supports arm64 for Apple Silicon Macs)
image: browserless/chrome:latest
container_name: firehose-playwright-chrome
ports:
- "3000:3000" # browserless dashboard (not required but handy)
environment:
- CONNECTION_TIMEOUT=600000
- MAX_CONCURRENT_SESSIONS=10
- PREBOOT_CHROME=true
- DEMO_MODE=false
- ENABLE_CORS=true
restart: unless-stopped
# Run as non-root to avoid permission issues
user: "1001:1001"
+297
View File
@@ -0,0 +1,297 @@
defmodule Firehose.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Firehose.Repo
alias Firehose.Accounts.{User, UserNotifier, UserToken}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `Firehose.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `Firehose.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user with the given magic link token.
"""
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Logs the user in by magic link.
There are three cases to consider:
1. The user has already confirmed their email. They are logged in
and the magic link is expired.
2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
{user, token} ->
Repo.delete!(token)
{:ok, {user, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given user.
"""
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end
+33
View File
@@ -0,0 +1,33 @@
defmodule Firehose.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `Firehose.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias Firehose.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end
+132
View File
@@ -0,0 +1,132 @@
defmodule Firehose.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, Firehose.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Firehose.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end
@@ -0,0 +1,97 @@
defmodule Firehose.Accounts.UserNotifier do
@moduledoc """
Sends notification emails to users.
Handles delivery of login instructions (magic link or confirmation),
email update instructions, and other account-related notifications.
"""
import Swoosh.Email
alias Firehose.Accounts.User
alias Firehose.Mailer
defp sender do
name = System.get_env("SENDER_NAME") || "Firehose"
email = System.get_env("SMTP_FROM_EMAIL") || "contact@example.com"
{name, email}
end
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from(sender())
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
defp deliver_magic_link_instructions(user, url) do
deliver(user.email, "Log in instructions", """
==============================
Hi #{user.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end
+156
View File
@@ -0,0 +1,156 @@
defmodule Firehose.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Firehose.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, Firehose.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{user, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks whether the token has expired. The context
of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to change the user
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end
+1
View File
@@ -12,6 +12,7 @@ defmodule Firehose.Application do
Firehose.Repo,
{DNSCluster, query: Application.get_env(:firehose, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Firehose.PubSub},
{Microprints.MicroprintCache, pubsub: Firehose.PubSub},
# Start a worker by calling: Firehose.Worker.start_link(arg)
# {Firehose.Worker, arg},
# Start to serve requests, typically the last entry
@@ -420,6 +420,30 @@ defmodule FirehoseWeb.CoreComponents do
"""
end
@doc """
Renders an RSS feed icon (inline SVG).
## Examples
<.rss_icon class="size-5" />
"""
attr :class, :string, default: "size-4"
attr :rest, :global
def rss_icon(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={[@class]}
{@rest}
>
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93v-2.83Z" />
</svg>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
@@ -9,8 +9,30 @@ defmodule FirehoseWeb.Layouts do
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Stores content in the connection for later rendering in layouts.
Used to inject dynamic meta tags into the `<head>` section.
"""
def put_content_for(conn, key, content) do
content_for = Map.get(conn.assigns, :content_for, %{})
new_content_for = Map.update(content_for, key, [content], &(&1 ++ [content]))
Plug.Conn.assign(conn, :content_for, new_content_for)
end
@doc """
Retrieves content stored via `put_content_for/3`.
Can be called from HEEx templates with `get_content_for(assigns, :meta_tags)`.
"""
def get_content_for(assigns, key) do
content_for = assigns[:content_for]
if is_map(content_for), do: Map.get(content_for, key, []), else: []
end
@doc """
Shows the flash group with standard titles and content.
@@ -4,13 +4,13 @@
href="/"
class="font-display text-xl font-semibold tracking-tight text-primary hover:opacity-80 transition"
>
firehose
Willem's firehose
</a>
</div>
<div class="flex-none">
<ul class="flex flex-row px-1 space-x-2 sm:space-x-4 items-center">
<li>
<a href="/blog/engineering" class="btn btn-ghost btn-sm">Engineering</a>
<a href="/blog/engineering" class="btn btn-ghost btn-sm">Blog</a>
</li>
<li>
<a href="/blog/releases" class="btn btn-ghost btn-sm">Releases</a>
@@ -20,6 +20,12 @@
QWAN
</a>
</li>
<li>
<a href="/contact" class="btn btn-ghost btn-sm">Contact</a>
</li>
<li :if={@current_scope && @current_scope.user}>
<a href="/editor/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
</li>
<li>
<.theme_toggle />
</li>
@@ -33,4 +39,30 @@
</div>
</main>
<footer class="border-t border-base-200 px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-3">
<h3 class="text-sm font-semibold tracking-wide text-base-content/60 uppercase">Subscribe</h3>
<div class="flex flex-wrap gap-4">
<a
href="/api/blog/engineering/feed.xml"
class="inline-flex items-center gap-2 text-sm text-base-content/70 hover:text-primary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Engineering Blog
</a>
<a
href="/api/blog/releases/feed.xml"
class="inline-flex items-center gap-2 text-sm text-base-content/70 hover:text-primary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Release Notes
</a>
</div>
</div>
</footer>
<.flash_group flash={@flash} />
@@ -31,6 +31,19 @@
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
<%= for meta <- get_content_for(assigns, :meta_tags) do %>
<meta property="og:title" content={meta.og_title} />
<meta property="og:description" content={meta.og_description} />
<meta property="og:type" content={meta.og_type} />
<meta property="og:url" content={meta.og_url} />
<%= if meta.og_image do %>
<meta property="og:image" content={meta.og_image} />
<% end %>
<meta name="twitter:card" content={meta.twitter_card} />
<% end %>
<%= for feed <- get_content_for(assigns, :feed_links) do %>
<link rel="alternate" type="application/rss+xml" title={feed.title} href={feed.rss_url} />
<% end %>
</head>
<body>
{@inner_content}
@@ -8,38 +8,62 @@ defmodule FirehoseWeb.BlogController do
page = parse_page(params["page"])
result = blog.paginate(page)
render(conn, :index,
meta = Blogex.SEO.meta_tags_for_blog(blog, FirehoseWeb.Endpoint.url())
rss_url = rss_feed_url(blog)
conn
|> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta)
|> FirehoseWeb.Layouts.put_content_for(:feed_links, %{rss_url: rss_url, title: blog.title()})
|> render(:index,
page_title: blog.title(),
blog_title: blog.title(),
blog_description: blog.description(),
posts: result.entries,
base_path: blog.base_path(),
page: result.page,
total_pages: result.total_pages
total_pages: result.total_pages,
rss_feed_url: rss_url
)
end
def show(conn, %{"slug" => slug}) do
blog = conn.assigns.blog
post = blog.get_post!(slug)
visibility = Blogex.Post.visibility(post)
rss_url = rss_feed_url(blog)
render(conn, :show,
meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog)
conn
|> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta)
|> FirehoseWeb.Layouts.put_content_for(:feed_links, %{rss_url: rss_url, title: blog.title()})
|> render(:show,
page_title: post.title,
post: post,
base_path: blog.base_path()
meta: meta,
base_path: blog.base_path(),
visibility: visibility,
authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user),
rss_feed_url: rss_url
)
end
def tag(conn, %{"tag" => tag}) do
blog = conn.assigns.blog
posts = blog.posts_by_tag(tag)
rss_url = rss_feed_url(blog)
render(conn, :tag,
rss_link = %{rss_url: rss_url, title: blog.title()}
conn
|> FirehoseWeb.Layouts.put_content_for(:feed_links, rss_link)
|> render(:tag,
page_title: "#{blog.title()}#{tag}",
blog_title: blog.title(),
tag: tag,
posts: posts,
base_path: blog.base_path()
base_path: blog.base_path(),
rss_feed_url: rss_url
)
end
@@ -65,4 +89,9 @@ defmodule FirehoseWeb.BlogController do
_ -> 1
end
end
defp rss_feed_url(blog) do
base_id = blog.base_path() |> String.trim_leading("/blog/")
FirehoseWeb.Endpoint.url() <> "/api/blog/#{base_id}/feed.xml"
end
end
@@ -1,6 +1,17 @@
<div class="space-y-8">
<header>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to {@blog_title} RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<p :if={@blog_description} class="mt-2 text-base-content/70">{@blog_description}</p>
</header>
@@ -1,4 +1,34 @@
<div class="space-y-8">
<div class="flex items-center justify-between">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<%= if @authenticated and @visibility == :draft do %>
<div
class="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-amber-800 text-sm font-medium"
id="post-status-banner"
>
Draft — not published
</div>
<% end %>
<%= if @authenticated and @visibility == :scheduled do %>
<div
class="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-blue-800 text-sm font-medium"
id="post-status-banner"
>
This post is scheduled for {Calendar.strftime(@post.date, "%B %d, %Y")}
</div>
<% end %>
<.post_show post={@post} base_path={@base_path} />
</div>
@@ -1,6 +1,17 @@
<div class="space-y-8">
<header>
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold font-display">{@blog_title}</h1>
<a
href={@rss_feed_url}
class="link link-hover text-orange-500 hover:text-orange-600 transition-colors"
title="Subscribe to {@blog_title} RSS feed"
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-5" />
</a>
</div>
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
</header>
@@ -10,4 +10,8 @@ defmodule FirehoseWeb.PageController do
render(conn, :home, recent_posts: recent_posts)
end
def contact(conn, _params) do
render(conn, :contact)
end
end
@@ -0,0 +1,65 @@
<div class="space-y-12">
<section class="space-y-6">
<h1 class="text-4xl sm:text-5xl font-display font-semibold leading-tight tracking-tight text-balance">
Contact
</h1>
<div class="space-y-4 text-lg leading-relaxed text-base-content/80">
<p>
Got a question, idea, or just want to say hello? Reach out below.
</p>
</div>
</section>
<section class="space-y-6">
<h2 class="text-2xl font-display font-semibold">Get in touch</h2>
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="text-lg">📱</span>
<a
href="tel:+31612345678"
class="text-lg text-primary hover:underline"
>
+44 7 438 651 672
</a>
</div>
<div class="flex items-center gap-3">
<span class="text-lg">🔗</span>
<a
href="https://www.linkedin.com/in/willemvandenende"
class="text-lg text-primary hover:underline"
target="_blank"
rel="noopener"
>
LinkedIn
</a>
</div>
<div class="flex items-center gap-3">
<span class="text-lg">🌐</span>
<a
href="https://qwan.eu/contact"
class="text-lg text-primary hover:underline"
target="_blank"
rel="noopener"
>
QWAN contact page
</a>
</div>
</div>
</section>
<section class="space-y-6">
<h2 class="text-2xl font-display font-semibold">QWAN</h2>
<div class="space-y-4 text-lg leading-relaxed text-base-content/80">
<p>
I'm a partner at <a
href="https://qwan.eu"
class="text-primary hover:underline"
target="_blank"
rel="noopener"
>QWAN</a>,
where we build software based on user needs, and help others do the same, and learn together. If you're looking for collaboration, have a product you want to build, a service that needs improvement,
or just want to nerd out about agentic systems, feel free to reach out.
</p>
</div>
</section>
</div>
@@ -12,7 +12,7 @@
target="_blank"
rel="noopener"
>QWAN</a>.
This is where I write about AI-native consulting, shitty evals,
This is where I write about agentic engineering, wishcraft, naive evals,
and whatever prototype I'm building this week.
</p>
</div>
@@ -0,0 +1,44 @@
defmodule FirehoseWeb.UserRegistrationController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias Firehose.Accounts.User
def new(conn, _params) do
changeset = Accounts.change_user_email(%User{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
allowed_email = Application.get_env(:firehose, :allowed_registration_email)
if allowed_email == nil or user_params["email"] != allowed_email do
changeset =
%User{}
|> Accounts.change_user_email(user_params)
|> Ecto.Changeset.add_error(:email, "registration is invite only.")
|> Map.put(:action, :validate)
render(conn, :new, changeset: changeset)
else
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
conn
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> redirect(to: ~p"/users/log-in")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
end
@@ -0,0 +1,5 @@
defmodule FirehoseWeb.UserRegistrationHTML do
use FirehoseWeb, :html
embed_templates "user_registration_html/*"
end
@@ -0,0 +1,30 @@
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
</div>
@@ -0,0 +1,88 @@
defmodule FirehoseWeb.UserSessionController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias FirehoseWeb.UserAuth
def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
render(conn, :new, form: form)
end
# magic link login
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
info =
case params do
%{"_action" => "confirmed"} -> "User confirmed successfully."
_ -> "Welcome back!"
end
case Accounts.login_user_by_magic_link(token) do
{:ok, {user, _expired_tokens}} ->
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
{:error, :not_found} ->
conn
|> put_flash(:error, "The link is invalid or it has expired.")
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
end
end
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, "Welcome back!")
|> UserAuth.log_in_user(user, user_params)
else
form = Phoenix.Component.to_form(user_params, as: "user")
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> render(:new, form: form)
end
end
# magic link request
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
conn
|> put_flash(:info, info)
|> redirect(to: ~p"/users/log-in")
end
def confirm(conn, %{"token" => token}) do
if user = Accounts.get_user_by_magic_link_token(token) do
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
conn
|> assign(:user, user)
|> assign(:form, form)
|> render(:confirm)
else
conn
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> redirect(to: ~p"/users/log-in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end
@@ -0,0 +1,9 @@
defmodule FirehoseWeb.UserSessionHTML do
use FirehoseWeb, :html
embed_templates "user_session_html/*"
defp local_mail_adapter? do
Application.get_env(:firehose, Firehose.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end
@@ -0,0 +1,57 @@
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>Welcome {@user.email}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the user settings.
</p>
</div>
@@ -0,0 +1,71 @@
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
spellcheck="false"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>
@@ -0,0 +1,77 @@
defmodule FirehoseWeb.UserSettingsController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias FirehoseWeb.UserAuth
import FirehoseWeb.UserAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, :edit)
end
def update(conn, %{"action" => "update_email"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: ~p"/users/settings")
changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert})
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do
{:ok, {user, _}} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, ~p"/users/settings")
|> UserAuth.log_in_user(user)
{:error, changeset} ->
render(conn, :edit, password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: ~p"/users/settings")
{:error, _} ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: ~p"/users/settings")
end
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
end
@@ -0,0 +1,5 @@
defmodule FirehoseWeb.UserSettingsHTML do
use FirehoseWeb, :html
embed_templates "user_settings_html/*"
end
@@ -0,0 +1,47 @@
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
<input type="hidden" name="action" value="update_email" />
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">
Save Password
</.button>
</.form>
@@ -0,0 +1,117 @@
defmodule FirehoseWeb.EditorDashboardLive do
use FirehoseWeb, :live_view
alias Blogex.Post
@impl true
def mount(_params, _session, socket) do
all_posts = Blogex.Registry.all_posts_unfiltered()
drafts =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :draft))
|> Enum.sort_by(& &1.date, {:desc, Date})
scheduled =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :scheduled))
|> Enum.sort_by(& &1.date, {:asc, Date})
{:ok,
socket
|> assign(:page_title, "Editor Dashboard")
|> assign(:drafts, drafts)
|> assign(:scheduled, scheduled)
|> assign(:active_tab, :drafts)}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="flex gap-4 mb-6 border-b border-zinc-200">
<button
phx-click="switch_tab"
phx-value-tab="drafts"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :drafts,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-base-content/70 hover:text-base-content"
)
]}
>
Drafts ({length(@drafts)})
</button>
<button
phx-click="switch_tab"
phx-value-tab="scheduled"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :scheduled,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-base-content/70 hover:text-base-content"
)
]}
>
Scheduled ({length(@scheduled)})
</button>
</div>
<div id="drafts-tab" class={if(@active_tab != :drafts, do: "hidden")}>
<div :if={@drafts == []} class="text-base-content/70 text-sm">No drafts</div>
<div :for={post <- @drafts} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-base-content hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-base-content/70 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} ·
<span class="text-amber-600 font-medium">Draft</span>
</div>
</div>
</div>
</div>
</div>
<div id="scheduled-tab" class={if(@active_tab != :scheduled, do: "hidden")}>
<div :if={@scheduled == []} class="text-base-content/70 text-sm">No scheduled posts</div>
<div :for={post <- @scheduled} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-base-content hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-base-content/70 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} ·
<span class="text-blue-600 font-medium">
{Post.days_until_live(post)} days until live
</span>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))}
end
defp post_path(post) do
blog = Blogex.Registry.get_blog!(post.blog)
"#{blog.base_path()}/#{post.id}"
end
end
@@ -0,0 +1,374 @@
defmodule FirehoseWeb.MicroprintsLive do
use FirehoseWeb, :live_view
alias Microprints.MicroprintCache
alias Microprints.MicroprintComponent
@source_dirs ["app", "blogex"]
@impl true
def mount(params, _session, socket) do
files = scan_source_files_with_dir()
microprints =
files
|> Enum.map(&process_file/1)
{:ok,
socket
|> assign(:page_title, "Microprints")
|> assign(:microprints, microprints)
|> restore_state_from_params(params)}
rescue
e ->
{:ok,
socket
|> assign(:page_title, "Microprints")
|> assign(:microprints, [])
|> assign(:expanded_path, nil)
|> assign(:source_lines, nil)
|> assign(:highlighted_path, nil)
|> assign(:highlighted_line, nil)
|> put_flash(:error, "Error loading microprints: #{inspect(e)}")}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, restore_state_from_params(socket, params)}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Microprints</h1>
<p class="text-sm text-base-content/70 mb-4">
Visual fingerprints of source code files. Click a line to highlight it.
Click a card to expand and view the source.
</p>
<.microprint_legend />
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<%= for %{path: path, microprint: microprint, source_dir: _source_dir} = item <- @microprints do %>
{error = item[:error]}
<div class="card bg-base-100 shadow-sm border border-zinc-200">
<div class="card-body p-4">
<h3 class="text-sm font-mono font-medium truncate" title={path}>
{path}
</h3>
<%= if microprint do %>
<button
phx-click="toggle_expand"
phx-value-path={path}
class="btn btn-xs btn-ghost w-full"
>
<%= if @expanded_path == path do %>
Collapse
<% else %>
Expand
<% end %>
</button>
<.microprint
microprint={microprint}
width={200}
max_height={100}
clickable={true}
file_path={path}
highlighted_line={@highlighted_line}
/>
<%= if @expanded_path == path and @source_lines do %>
<.source_viewer
source_lines={@source_lines}
highlighted_line={@highlighted_line}
language="elixir"
file_path={path}
/>
<% end %>
<% else %>
<div class="text-xs text-red-500 mt-1">
Error: {inspect(error)}
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
"""
end
@impl true
def handle_event("highlight_line", %{"line" => line, "path" => path}, socket) do
highlighted =
case socket.assigns.highlighted_path do
^path -> nil
_ -> String.to_integer(line)
end
# Collapse any currently expanded file when highlighting a different file
expanded =
case socket.assigns.expanded_path do
^path -> socket.assigns.expanded_path
_ -> nil
end
socket =
socket
|> assign(:highlighted_path, path)
|> assign(:highlighted_line, highlighted)
|> assign(:expanded_path, expanded)
|> assign(:source_lines, nil)
{:noreply, push_patch(socket, to: build_params(socket))}
end
@impl true
def handle_event("toggle_expand", %{"path" => path}, socket) do
expanded =
case socket.assigns.expanded_path do
^path -> nil
_ -> path
end
source_lines =
case expanded do
nil ->
nil
^path ->
item = Enum.find(socket.assigns.microprints, &(&1.path == path))
if item && item.source_dir do
abs_path = resolve_source_path(item.source_dir, path)
read_source(abs_path)
else
nil
end
end
socket = socket |> assign(:expanded_path, expanded) |> assign(:source_lines, source_lines)
{:noreply, push_patch(socket, to: build_params(socket))}
end
# Private helpers
defp restore_state_from_params(socket, params) do
expanded_path = params["expanded"]
highlighted_path = params["highlighted"]
highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil
source_lines =
if expanded_path do
item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path))
if item && item.source_dir do
abs_path = resolve_source_path(item.source_dir, expanded_path)
read_source(abs_path)
end
end
socket
|> assign(:expanded_path, expanded_path)
|> assign(:source_lines, source_lines)
|> assign(:highlighted_path, highlighted_path)
|> assign(:highlighted_line, highlighted_line)
end
defp build_params(socket) do
params = %{}
params =
if socket.assigns.expanded_path do
Map.put(params, "expanded", socket.assigns.expanded_path)
else
params
end
params =
if socket.assigns.highlighted_path do
Map.put(params, "highlighted", socket.assigns.highlighted_path)
else
params
end
params =
if socket.assigns.highlighted_line do
params |> Map.put("line", socket.assigns.highlighted_line)
else
params
end
"/microprints" <> if params != %{}, do: "?" <> URI.encode_query(params), else: ""
end
defp sort_by_mtime(files) do
app_root = Mix.Project.project_file() |> Path.dirname()
files
|> Enum.map(fn file ->
abs_path = Path.join(app_root, file)
{file, File.stat!(abs_path).mtime}
end)
|> Enum.sort_by(fn {_file, mtime} -> mtime end, :desc)
|> Enum.map(fn {file, _mtime} -> file end)
end
defp sort_by_mtime_with_dir(tuples) do
app_root = Mix.Project.project_file() |> Path.dirname()
tuples
|> Enum.map(fn {path, dir} ->
abs_path = Path.join(app_root, path)
{{path, dir}, File.stat!(abs_path).mtime}
end)
|> Enum.sort_by(fn {{_path, _dir}, mtime} -> mtime end, :desc)
|> Enum.map(fn {{path, dir}, _mtime} -> {path, dir} end)
end
@doc false
def scan_source_files do
@source_dirs
|> Enum.flat_map(&collect_elixir_files/1)
|> Enum.uniq_by(&elem(&1, 0))
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
|> sort_by_mtime()
|> Enum.take(2)
end
@doc false
def scan_source_files_with_dir do
@source_dirs
|> Enum.flat_map(&collect_elixir_files/1)
|> Enum.uniq_by(&elem(&1, 0))
|> Enum.sort_by(&elem(&1, 0))
|> sort_by_mtime_with_dir()
|> Enum.take(2)
end
defp resolve_absolute_paths(files) do
app_root = Mix.Project.project_file() |> Path.dirname()
Enum.map(files, fn path ->
Path.expand(Path.join(app_root, path))
end)
end
defp excluded_path?(path) do
path =~ "/_build/" or
path =~ "/deps/" or
path =~ "/examples/" or
path =~ "/test/" or
path =~ "/lib_dev/"
end
defp collect_elixir_files(dir) do
app_root = Mix.Project.project_file() |> Path.dirname()
monorepo_root = app_root |> Path.dirname()
base = Path.join(monorepo_root, dir)
case File.dir?(base) do
true ->
base
|> Path.join("**/*.ex")
|> Path.wildcard()
|> Enum.filter(&File.regular?(&1))
|> Enum.reject(&excluded_path?/1)
|> Enum.map(&format_path_and_dir(&1, monorepo_root, dir))
false ->
[]
end
end
defp format_path_and_dir(path, monorepo_root, dir) do
relative = Path.relative_to(path, monorepo_root)
formatted =
case dir do
"app" -> String.replace_prefix(relative, "app/", "")
_ -> "../" <> relative
end
{formatted, dir}
end
defp process_file({rel_path, source_dir}) do
abs_path = resolve_absolute_paths([rel_path]) |> List.first()
case MicroprintCache.get_microprint(abs_path) do
{:ok, microprint} ->
# Add line numbers to each line for highlighting
lines_with_numbers =
microprint.lines
|> Enum.with_index(1)
|> Enum.map(fn {line, num} -> Map.put(line, :line_number, num) end)
%{
path: rel_path,
source_dir: source_dir,
microprint: Map.put(microprint, :lines, lines_with_numbers)
}
{:error, reason} ->
%{path: rel_path, microprint: nil, error: reason}
end
end
defp resolve_source_path(source_dir, rel_path) do
app_root = Mix.Project.project_file() |> Path.dirname()
monorepo_root = app_root |> Path.dirname()
base = Path.join(monorepo_root, source_dir)
Path.join(base, rel_path)
end
defp read_source(abs_path) do
case File.read(abs_path) do
{:ok, content} -> String.split(content, "\n")
{:error, _} -> nil
end
end
# Delegate to MicroprintComponent
defdelegate microprint(assigns), to: MicroprintComponent
defdelegate microprint_legend(assigns), to: MicroprintComponent
# Custom source_viewer with unique DOM IDs per file to prevent LiveView
# DOM patching bugs when switching expanded files.
def source_viewer(assigns) do
assigns =
assign(
assigns,
:viewer_id,
"source-viewer-" <> Integer.to_string(:erlang.phash2(assigns.file_path, 1_000_000))
)
~H"""
<div
id={@viewer_id}
class="source-viewer mt-2 max-h-96 overflow-auto rounded font-mono text-xs"
phx-hook="SourceViewer"
data-highlighted-line={@highlighted_line}
data-language={@language}
style="background: var(--sv-bg); color: var(--sv-text);"
>
<div class="sv-lines p-2">
<%= for {line, num} <- Enum.with_index(@source_lines || [], 1) do %>
<div
id={"line-#{num}"}
class={"sv-line" <> if @highlighted_line == num, do: " sv-line-highlighted", else: ""}
>
<span class="sv-line-number">{num}</span>
<span class="sv-line-content">{line}</span>
</div>
<% end %>
</div>
</div>
"""
end
end
+42
View File
@@ -1,6 +1,8 @@
defmodule FirehoseWeb.Router do
use FirehoseWeb, :router
import FirehoseWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -9,6 +11,7 @@ defmodule FirehoseWeb.Router do
plug :put_layout, html: {FirehoseWeb.Layouts, :app}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
@@ -19,6 +22,7 @@ defmodule FirehoseWeb.Router do
pipe_through :browser
get "/", PageController, :home
get "/contact", PageController, :contact
end
scope "/blog", FirehoseWeb do
@@ -29,6 +33,12 @@ defmodule FirehoseWeb.Router do
get "/:blog_id/:slug", BlogController, :show
end
scope "/", FirehoseWeb do
pipe_through :browser
live "/microprints", MicroprintsLive
end
# JSON API + feeds (no Phoenix layout)
scope "/api/blog" do
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
@@ -51,4 +61,36 @@ defmodule FirehoseWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", FirehoseWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", FirehoseWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :authenticated_user,
layout: {FirehoseWeb.Layouts, :app},
on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", FirehoseWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
+266
View File
@@ -0,0 +1,266 @@
defmodule FirehoseWeb.UserAuth do
@moduledoc """
Handles user authentication for the web layer.
Provides plugs and callbacks for logging users in and out,
managing session tokens, enforcing authentication requirements,
and integrating with LiveView via `on_mount` callbacks.
"""
use FirehoseWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Firehose.Accounts
alias Firehose.Accounts.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_firehose_web_user_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
conn
|> create_or_extend_session(user, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
FirehoseWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
else
nil
end
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, user, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn, _user) do
# delete_csrf_token()
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn, _user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:user_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
put_session(conn, :user_token, token)
end
@doc """
Plug for routes that require sudo mode.
"""
def require_sudo_mode(conn, _opts) do
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
conn
else
conn
|> put_flash(:error, "You must re-authenticate to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
@doc """
Plug for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
defp signed_in_path(_conn), do: ~p"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
@doc """
LiveView on_mount callback that ensures the user is authenticated.
Used in `live_session` blocks in the router:
live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
"""
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
scope_from_session(session)
end)
end
defp scope_from_session(%{"user_token" => token}) do
case Accounts.get_user_by_session_token(token) do
{user, _token_inserted_at} -> Scope.for_user(user)
nil -> Scope.for_user(nil)
end
end
defp scope_from_session(_session), do: Scope.for_user(nil)
end
+6 -1
View File
@@ -4,7 +4,7 @@ defmodule Firehose.MixProject do
def project do
[
app: :firehose,
version: "0.1.0",
version: "0.2.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -41,6 +41,7 @@ defmodule Firehose.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
@@ -60,6 +61,7 @@ defmodule Firehose.MixProject do
compile: false,
depth: 1},
{:swoosh, "~> 1.16"},
{:gen_smtp, "~> 1.0"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
@@ -68,6 +70,9 @@ defmodule Firehose.MixProject do
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:blogex, path: "../blogex"},
{:microprints,
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
branch: "main"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end
+10 -2
View File
@@ -1,7 +1,9 @@
%{
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
@@ -15,21 +17,24 @@
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
"microprints": {:git, "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", "29ef59ff6eb41853b6f91872d8fffdfba4d85a62", [branch: "main"]},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_publisher": {:hex, :nimble_publisher, "1.1.1", "3ea4d4cfca45b11a5377bce7608367a9ddd7e717a9098161d8439eca23e239aa", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d67e15bddf07e8c60f75849008b78ea8c6b2b4ae8e3f882ccf0a22d57bd42ed0"},
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@@ -41,7 +46,10 @@
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig, devices } from "@playwright/test";
// Docker Chrome Playwright configuration.
// Connects to a remote browserless/chrome container via CDP.
//
// Usage:
// 1. Start the Chrome container:
// docker compose -f docker-compose.chrome.yml up -d
//
// 2. Run tests:
// npx playwright test --config=playwright.docker.config.ts
const CDP_URL = process.env.PLAYWRIGHT_CDP_URL || "http://localhost:3000";
export default defineConfig({
testDir: "./test/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "list",
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:8056",
trace: "on-first-retry",
headless: process.env.PLAYWRIGHT_HEADLESS !== "false",
},
// Connect to remote Chrome via CDP instead of launching locally.
// browserless/chrome exposes CDP at port 3000.
projects: [
{
name: "docker-chrome",
use: {
...devices["Desktop Chrome"],
launchOptions: {
// Not used for remote connection, but placeholder for clarity
},
},
},
],
});
@@ -3,6 +3,7 @@
author: "Willem van den Ende",
tags: ~w(meta ai),
description: "Why I built a separate personal blog, and what it has to do with drinking from the firehose.",
image: "/images/firehose-logo.png",
published: true
}
---
@@ -9,4 +9,10 @@
I had started on a "Write your own coding agent" exercise. Four iterations in, actually. And then I found [Tiny Agents]( https://tinyagents.dev/lesson/agent-loop), a set of interactive exercises that let you experience how agents work, from a simple chat request, through a tool, more tools etc. It has a live graph, that visualises of the flow of data and actions.
It is good fun to play with, it starts simple and builds up. It lets you inspect the messages between the 'agent' loop code and the large language model server (which is just HTTP and some JSON).
It is good fun to play with, it starts simple and builds up. It lets you inspect the messages between the 'agent' loop code and the large language model server (which is just HTTP and som![](app/priv/static/images/blog/2026/agent-nearby_20260701_220759.png)e JSON).
![]/images/blog/2026/Screen![](app/priv/static/images/blog/2026/agent-nearby_20260701_220849.png)shot 2026-06-30 at 22.42.49.png)
![agent nearby](/images/blog/2026/agent-nearby.png)
![](app/priv/static/images/blog/2026/agent-nearby_20260701_220606.png)
@@ -9,7 +9,7 @@
I made a skill for a coding agent to help me get more of my draft blog posts over the line. I enjoy writing, and am somewhat fluent in it. Publishing that writing is more hit and miss, however. I often lose energy just before a piece is finished enough. I want to publish more often, and need to form a more effective habit for it.
# What did I get out if it?
# What did I get out if it?
I got a working agent 'skill' in an hour or so. I like the QWEN models for their no-bullshit approach to feedback. As it turns out, I have about 60 pages with the 'Candidate Blogpost' tag in my notes, but most of them are not more than an idea. Only some of them have enough detail to turn into a post. I am going to keep this around, prune my candidate blogposts, and add my recent clippings to the mix.
@@ -26,13 +26,12 @@ I like "The Art of Action" - detailed, yet practical. So I had a chat with a fro
This was my initial prompt. Full chat transcript in the Further Reading section.
#+begin_quote
https://www.chrismdp.com/stop-prompting-start-briefing/ suggests an art of action style backbriefing loop for daily work. I would like to use a local model with pi, the shitty coding agent, instead of claude code. I have trouble publishing blogposts. I have many drafts, marked as CandidateBlogPost in an org-roam directory. I wonder if I could make some kind of pi extension or skill that finds candidate blogposts, helps identify ones that are almost finished, with a suggesion on what to do next for the top 3 almost finished, and suggestions for others on what to add. Probably prioritize recency. I could run that as a cron job in the morning, and create a new daily entry (I use daily entries for org-roam) to get me starte.d Goal would be not to have AI write my posts, but help me finish in pomodori instead of days.
#+end_quote
> https://www.chrismdp.com/stop-prompting-start-briefing/ suggests an art of action style backbriefing loop for daily work. I would like to use a local model with pi, the shitty coding agent, instead of claude code. I have trouble publishing blogposts. I have many drafts, marked as CandidateBlogPost in an org-roam directory. I wonder if I could make some kind of pi extension or skill that finds candidate blogposts, helps identify ones that are almost finished, with a suggesion on what to do next for the top 3 almost finished, and suggestions for others on what to add. Probably prioritize recency. I could run that as a cron job in the morning, and create a new daily entry (I use daily entries for org-roam) to get me starte.d Goal would be not to have AI write my posts, but help me finish in pomodori instead of days.
What I found interesting was that, maybe because I mentioned the links were in an sqlite database, claude desktop spontaneously suggested to create a bash script as part of the skill. I used to have a meta-skill to separate the deterministic parts of agent skills into scripts, but that does not seem to be necessary anymore. I prune my agent setups continuously, only keeping what is needed.
# Tradeoffs
# Tradeoffs
Initially I planned to run this as a scheduled job, but from the development chat it emerged that backbriefing (improving the skill as we run it daily) would not work if it runs scheduled.
@@ -0,0 +1,253 @@
%{
title: "Automated release notes with 'demo' skill",
author: "Willem van den Ende",
tags: ~w(ai),
description: "Agents can make release notes, given the right tools and instructions. Showboat, Chrome DevTools MCP were quite easy to use. ",
published: true
}
---
Yesterday, I added scheduled posts to firehose. I let my coding agent write the [release notes](blog/releases/scheduled-publishing). It is fun to read the release notes like this, and see the screenshots. Do note that a coding agent might not care there is no navigation - it happily will remember the urls and navigate to them, so that is something you might want to double check with exploratory testing :-).
I've included the full skill below. It is not in the repository, it went straight to my home directory so I can use it in other projects. This is the second project I have used it in. It helped me earlier do a fairly large architectural spike for an enterprise client, and include screenshots of a working application in the response document. Working software is still a great measure of progress.
# Demo skill - let your agent generate demos with screenshots for you
---
name: demo
description: >-
Generate a living demo document proving a feature works. Uses showboat
for Markdown assembly with captured command output and rodney for Chrome
browser screenshots. Use when the user says "demo this", "show me it works",
"create a demo", or after /build completes.
argument-hint: "<feature-name> [--scenario <description>] [--plan <path>]"
user-invocable: true
allowed-tools: Read, Write, Glob, Grep, Bash
---
# Demo
Role: worker. This command generates a standalone Markdown demo document that proves a feature works, using showboat for document assembly and rodney for browser automation.
You have been invoked with the `/demo` command.
## Parse Arguments
Arguments: $ARGUMENTS
- Positional: `<feature-name>` (required) — short name or description of the feature to demo
- `--scenario <description>`: Explicit demo scenario describing what to show. If omitted, infer from the plan and recent commits.
- `--plan <path>`: Path to the plan file. If omitted, search `plans/` for the most recently modified `.md` file with status `implemented` or `approved`.
## Steps
### 1. Check tool availability
Verify showboat and rodney are installed:
```bash
showboat --help 2>/dev/null && echo "showboat: ok" || echo "showboat: missing"
rodney --help 2>/dev/null && echo "rodney: ok" || echo "rodney: missing"
```
If either tool is missing, tell the user:
> One or more demo tools are missing. Install them with:
> ```bash
> go install github.com/simonw/showboat@latest
> go install github.com/simonw/rodney@latest
> ```
Do not proceed until both tools are confirmed available.
### 2. Gather feature context
Build an understanding of what to demo from these sources (in priority order):
1. **Explicit scenario** (`--scenario`): If provided, use as primary guide.
2. **Plan file**: Read the plan's Goal, Acceptance Criteria, and completed Steps.
3. **Recent commits**: Run `git log --oneline -15` and `git diff main...HEAD --stat` to identify changed files and commit messages.
4. **Route map**: Cross-reference changed files against known LiveView routes in the router (`lib/hub_web/router.ex`).
From these sources, produce a **demo outline**:
- **Narrative**: 2-3 sentence description of what the feature does
- **Backend evidence**: mix commands, test output, or database queries to run
- **UI pages**: which routes to visit and what to look for
- **Interactions**: any clicks, form fills, or navigation sequences to perform
If no plan exists and commits are ambiguous, ask the user for a brief description of what to demo.
### 3. Check dev server
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ 2>/dev/null
```
If the server is not reachable:
> The Phoenix dev server is not running. Start it now?
> ```bash
> mix phx.server &
> ```
After starting, wait up to 10 seconds and verify connectivity. If it still fails, proceed with backend-only evidence (skip all browser screenshots) and note the limitation in the demo document.
### 4. Handle authentication
The app requires authentication for LiveView routes. Before capturing UI screenshots:
1. Ensure a demo user exists:
```bash
mix run -e "
alias Hub.Accounts
case Accounts.get_user_by_email(\"demo@example.com\") do
nil -> Accounts.register_user(%{email: \"demo@example.com\", password: \"demodemo1234\"})
user -> {:ok, user}
end
"
```
2. Log in via rodney:
```bash
rodney start
rodney open http://localhost:4000/users/log-in
rodney wait "input[name='user[email]']"
rodney input "input[name='user[email]']" "demo@example.com"
rodney input "input[name='user[password]']" "demodemo1234"
rodney click "button[type='submit']"
rodney waitidle
```
If login fails, warn and proceed with backend-only evidence.
### 5. Initialize the demo document
Slugify the feature name (lowercase, hyphens, no special chars). Then:
```bash
showboat init "demos/demo-$(date +%Y%m%d-%H%M%S)-<slug>.md" "Demo: <Feature Name>"
```
Store the demo file path for use in all subsequent steps.
### 6. Narrative introduction
```bash
showboat note <demo-file> "## Feature Overview
<2-3 sentence description derived from the plan or commits.>
**Branch**: $(git branch --show-current)
**Commits**: <N> commits ahead of main
**Plan**: <plan file path or 'none'>
"
```
### 7. Backend evidence
Capture backend proof via `showboat exec`. Always include relevant tests. Add narrative notes between evidence blocks explaining what each proves.
**Test output** (always include):
```bash
showboat note <demo-file> "## Test Suite"
showboat exec <demo-file> bash "mix test <relevant-test-files> --color"
showboat note <demo-file> "All <N> tests pass, confirming <specific criterion>."
```
**Compilation check**:
```bash
showboat note <demo-file> "## Compilation Check"
showboat exec <demo-file> bash "mix compile --warnings-as-errors"
```
**Database state** (if relevant to the feature):
```bash
showboat note <demo-file> "## Database State"
showboat exec <demo-file> bash "mix run -e '<query expression>'"
```
### 8. UI screenshots
For each UI page identified in step 2, navigate with rodney, screenshot, and embed via showboat.
**Static page capture**:
```bash
rodney open http://localhost:4000/<route>
rodney waitidle
rodney screenshot demos/screenshots/<feature>-<page-name>.png
showboat note <demo-file> "### <Page Name>
<What this page shows and why it proves the feature works.>"
showboat image <demo-file> '![<What the screenshot demonstrates>](demos/screenshots/<feature>-<page-name>.png)'
```
**Interactive flow** (form submissions, navigation):
```bash
showboat note <demo-file> "### Interactive Flow: <Flow Name>"
# Before state
rodney screenshot demos/screenshots/<feature>-before.png
showboat image <demo-file> '![Before: <initial state>](demos/screenshots/<feature>-before.png)'
# Perform interaction
rodney click "<selector>"
rodney input "<selector>" "<value>"
rodney click "<submit-selector>"
rodney waitidle
# After state
rodney screenshot demos/screenshots/<feature>-after.png
showboat image <demo-file> '![After: <resulting state>](demos/screenshots/<feature>-after.png)'
```
### 9. Acceptance criteria checklist
If a plan file exists, map each acceptance criterion to evidence:
```bash
showboat note <demo-file> "## Acceptance Criteria Verification
- [x] <Criterion 1> -- see Test Suite output above
- [x] <Criterion 2> -- see <Page Name> screenshot
- [x] <Criterion 3> -- see Database State output
"
```
If no plan, summarize what was demonstrated and what it proves.
### 10. Clean up
```bash
rodney stop 2>/dev/null || true
```
### 11. Report results
Display:
```
## Demo Complete
- **Document**: demos/<filename>.md
- **Screenshots**: <N> captured in demos/screenshots/
- **Evidence**: <N> backend commands, <N> UI screenshots
- **Acceptance criteria**: <N>/<M> demonstrated
```
## Error Handling
- **Tools not installed**: Show `go install` commands. Do not proceed without them.
- **Dev server not running**: Offer to start. If startup fails, produce backend-only demo and note the limitation.
- **Authentication failure**: Proceed with backend-only evidence. Note skipped UI screenshots in the document.
- **Screenshot failure**: Log the error as a note in the demo document, continue with remaining screenshots.
- **No plan found**: Infer from git commits and changed files. Ask the user for a description if commits are ambiguous.
- **Rodney/Chrome crash**: Run `rodney stop` then `rodney start` to reset. Retry once. If it fails again, degrade to backend-only.
## Integration
- `/build` can suggest running `/demo` after a successful build
- `/pr` can invoke `/demo` to generate evidence before or after PR creation
- `/plan` produces the acceptance criteria this command verifies visually
- Beads (`bd`) can reference demo documents as task completion evidence
- Reads plan files from `plans/` directory (same format as `/build`)
@@ -0,0 +1,151 @@
%{
title: "Prototyping a thin slice for federated corporate collaboration",
author: "Willem van den Ende",
tags: ~w(ai prototyping design-principles),
description: "Why prototype when you only hardly have time to write a reply to a detailed enterprese question? I made a prototype to reflect my thinking, understand an enterprise problem and explore the solution space. Rapidly.",
published: true
}
---
# Prototyping fairly deep and fast
This text from "The problem" section onwards was lightly edited by me, the first pass was generated by a coding agent, based on the session the prototype was developed in. It is not the tone I would write in, but there is something to it. I've written more commentary on the writing and development process in the _Afterword_.
*Prototyping is like a mirror - what is wanted (and what not) is clearer after it has been built. Building quality in, even for a prototype, can let the exploration go deeper, in more detail without spiraling out of control.*
A collaboratory is a metaphor for a federated intranet-like setup, where various parties can share information. Each party can have a requisite variety of detail for their own operations, and share what is beneficial and legal to share through a central system.
I spent a day building a prototype to get clarity on the solution space for a problem in a corporate client, based on three design principles outlined below. ACME corporation and its clients are, of course, fictional. I have seen and solved similar problems in other companies - also in government.
I left the 'several weeks' remarks in the text - it would easily have cost this building completely by hand with a small team.
A full collaboratory would consist of many slices. I focused on one slice, a fictional sharing of cases, to illustrate the idea, answer some of our hypotheses and drive out more questions. Working, full stack, software is a great conversation starter.
## The problem
Acme Corporation is a sprawling professional-services group. Dozens of business units (BUs), tens of thousands of people, and every year a pile of bids going out to clients that — more often than not — other parts of Acme have already worked with.
The BUs bid in silos. Cross-sell signals are invisible. Referrals happen by accident, over coffee, between people who happen to know each other. Every BU has its own spreadsheet, its own "case list", its own definition of what a "won bid" means. And every attempt to build a single central system has failed — either because a BU refuses to hand over sensitive financial data, or because the thing that gets built is so generic no team actually uses it.
The challenge here is editorial, not technical. Getting BUs to contribute useful information in a form that other BUs can act on — without leaking margin, PII, or export-controlled content — is the hard part.
I built a proof of concept to test whether there's a shape of system that actually threads this needle. Here's what came out of it, and the three principles I'd stand behind if I had to pitch this to a CFO.
## What I built
A Phoenix LiveView app called **Case Insights**. Each BU runs its own instance — a *spoke*. A thin central service — the *Hub* — receives redacted projections of closed cases from every spoke and exposes cross-BU intelligence back to anyone who needs it.
![Case Insights homepage](/images/blog/2026/collaboratory/phases-3b-4-homepage.png)
Feature-complete-enough-to-demo in a handful of weeks:
- **Case Explorer** — browse, filter, and drill into cases owned by your BU, with full parent/child case trees.
- **Bid Preparation** — type in a prospective client and get a three-tier answer: "your BU has worked with them before" / "another Acme BU has worked with them" / "here's what peers in your sector have done with similar clients".
- **Nomination flow** — a lightweight "ask a colleague" loop for pulling in knowledge that lives in someone's head rather than a system.
- **Analytics** — portfolio summaries, lifetime-value-by-client, win-reason breakdowns.
- **CSV import** — three-step wizard for bulk-seeding a BU from whatever spreadsheet they already have.
- **Hub export pipeline** — the piece that makes federation actually work.
![Bid preparation results](/images/blog/2026/collaboratory/bid-preparation-results.png)
313 tests passing. Real redaction. Real versioned exports. Not a mock.
## Principle 1 — Federation, not centralisation
Every attempt I've seen at "one central system for the whole group" has either failed politically or decayed into a lowest-common-denominator tool that nobody trusts. So I didn't build one.
Instead: **each BU owns their own spoke**. Their data lives in their instance. Their financial detail never leaves unless they choose to expose it, and even then, never raw. The Hub is thin on purpose — it holds a redacted projection and nothing else.
And crucially, BUs can opt in at three different levels, depending on how much appetite they have:
![Federalization, not centralization - tiers and structure explained below. Central Hub gets cross-Bid BU intelligenge from three tiers](/images/blog/2026/collaboratory/principle_1_federation_not_centralisation.png)
- **Tier 1 — Reference implementation.** Run the open-source spoke as-is. Fastest path. Best for BUs without a CRM or with a weak one.
- **Tier 2 — Adapter.** Keep your existing case/CRM system. Write a small adapter that maps your records onto the Hub's export contract. Gives you federation without a migration.
- **Tier 3 — Plug in to central.** No local infrastructure at all. Users log into a shared central instance and contribute directly. Useful for small BUs where running a spoke isn't worth it.
A CFO can roll this out without a big-bang. Pick the BUs with the hungriest bid managers, run them at Tier 1. Let reluctant BUs sit at Tier 3. Let the BUs with entrenched systems do Tier 2 on their own timeline. Each tier delivers value independently. Nothing blocks on everyone agreeing.
## Principle 2 — Loosely coupled, cohesive
The whole point is to preserve BU autonomy while enabling cross-BU intelligence. These two things sound contradictory but they're not — they just need the contract between Hub and spoke to be very narrow.
The Hub sees:
- **Who** (client name, sector)
- **Which BU** worked with them
- **When** (year)
- **Outcome** (won / lost, a summary, a narrative)
- **Rough scale** (a value *band*, not a number)
- **Structural hints** (does this case have follow-on work? Is it linked to a parent case?)
The Hub does **not** see:
- Margin, staff retention, employee satisfaction
- PII — contact names, emails, phone numbers
- The raw financials
- Who submitted the case
- Internal notes that didn't make it into the narrative field
Every BU keeps full control of everything inside their own instance. What they choose to publish is bounded by the contract, not by trust in the central team.
## Principle 3 — Data Loss Prevention at the source
This is the bit I'm proudest of, and the bit that makes this architecture different from the usual "dump it in a data lake and scrub it later" pattern.
DLP happens at the application layer, *before* the data leaves the spoke. Not in a gateway. Not in a post-hoc scan. In the code that writes the export record.
Four mechanisms, composed:
1. **Field filtering.** Sensitive fields are physically absent from the exported payload. Margin, staff retention, PII — not redacted, not masked, *not in the structure*. The Hub can't leak what it never receives.
2. **Content filtering.** Raw financial values are translated into bands (`<500K`, `500K2M`, `2M+`) at export time. A reader on the Hub can see that a deal was "meaningful" without knowing the number.
3. **Per-field visibility tiers.** Inside a BU, the same case is rendered differently depending on who's looking at it — owner, BU admin, BU member, cross-BU viewer, or hidden entirely. One policy, applied consistently everywhere the case is read.
4. **Export restrictions.** Cases can be withheld entirely based on policy — draft, incomplete, or (in the next iteration) country-specific export controls like ITAR or EU dual-use. The spec has a slot for a content scanner that inspects narrative text for export-controlled material. The Hub only ever receives what's cleared.
Versioning is built in. Every export is numbered. If a BU enriches a case after the fact, the Hub gets a new version and can reconcile. Nothing is ever silently overwritten.
![Analytics dashboard](/images/blog/2026/collaboratory/analytics-summary-and-charts.png)
## Why a POC got this far, this fast
Three things made the build feel unreasonably quick:
- **Spec-first design.** I wrote the domain rules in a spec language ([Allium](https://juxt.github.io/allium/)) before writing any code. Entities, enums, state machines, redaction rules, access-control tiers — all of it agreed on paper first. When it came time to code, the shape was already locked. Almost no rework.
- **AI-assisted build loop.** The specs are machine-readable, so I could hand them to an LLM alongside the existing code and get implementation that actually matched the intent. The rare drift got caught fast because the spec was the source of truth.
- **Phoenix LiveView.** Server-rendered reactivity. I built the whole UI — case explorer, bid prep wizard, nomination flow, CSV import, analytics — without a JavaScript framework. Forms, tables, charts, modals, all in one codebase.
The hard problem was never the code. It was agreeing *what* to build. Once the spec was right, the build was a formality.
## What a CFO should take away
- **Federation is achievable.** You don't need to centralise to get cross-BU intelligence. A thin Hub plus narrow contracts plus per-BU spokes beats a mega-system.
- **DLP at source is cheaper than DLP at the perimeter.** Filtering in the application layer, with the domain model in front of you, is vastly easier than trying to scrub a lake after the fact. It's also legally cleaner — the sensitive data never left the BU.
- **Opt-in tiers avoid the big-bang trap.** The reason central systems fail isn't usually technical — it's that rollout requires unanimous agreement. Three tiers let each BU join on their own terms. Value compounds as more BUs join, but no single BU is blocking the others.
- **Spec-first design pays back quickly.** A week of elicitation saved weeks of rework. In a world where the LLMs can write most of the code, the bottleneck moves to understanding what you want.
If you're at a large organisation and you're still trying to solve a cross-BU knowledge problem by building a single centralised CRM, this is the shape I'd argue you should be looking at instead. It's not a data lake. It's not a warehouse. It's a federation of small, opinionated tools that share one narrow contract, with DLP baked into the contract itself.
The POC exists. The redaction works. The tests pass. It would ship.
# Afterword
(written by hand)
I created this and the follow on blog post with a single prompt (for a plan) and some iteration on what the audience should be etc.
I told the agent to write for a more general audience. The "What a CFO should take away" section is a good example of that. "Why a PO got this far, this fast" does get a bit more technical than I expected. See it as an FYI, but I am confident some of my more technical readers will appreciate it.
I did find some defects in the software preparing the blog posts. One of the screenshots in the auto-generated demo contained a clear error. Working in somewhat smaller steps could have prevented this.
I wanted to see where the solution went, quickly. For production work I check the tests and code against the spec, update any of them as needed, and look more closely at what was produced, including e.g. exploratory testing and user feedback.
The agent mentions "Spec-first development". There was a formal specification involved, not just text. The process was more an iterative conversation than writing the spec out in full before starting. But this was hours, not weeks as it was just me and the animatronic rubber duck in the chat. Making tacit knowledge explicit takes people and time. Working software can help to flush that out, combined with workshops. More about the formal specification in the next post.
# Acknowledgements
Thanks to [Antony Marcano](https://antonymarcano.com/) for feedback on the first version. I moved my commentary in to the afterword to improve reading flow. Mixing my comments with the agents narrative was indeed confusing.
@@ -0,0 +1,27 @@
%{
title: "Do you need a virtual Jamie Dobson?",
author: "Willem van den Ende",
tags: ~w(writing ai),
description: "Writing and publishing have multiple goals and audiences. My hand written drafts rarely make it out to the public, unless it is a comment or a micro post. AI generated makes it out, but may confuse readers. Handwritten post.",
published: true
}
---
Most of my hand written notes don't make it out in to the wild. Even when someone (thanks [Mark Dalgarno](https://www.linkedin.com/in/markdalgarnouk/)) says: "this would be a good micro-post", and I have created this site to give me permission to quickly post something, it might not happen.
By the time I have written things down, my perspective may have shifted. If not then, it definitely has shifted by the time I get around to editing.
Over the half-term I have sat down with pen and (e-) paper. It really does help to slow me down and think. But I still need to open a laptop to edit and publish. This introduces friction. "Friction is good for learning!" I hear you say. If it is *essential friction*, yes. If it is *accidental friction*, not so much.
You see, I wanted to publish a piece on essential versus accidental friction, probably wrote a draft somewhere and then left it.
Jamie Dobson said on LinkedIn... So yeah, I went to that ad platform and couldn't find back the post, and I lost my train of thought. It was something along the lines of writing by hand, going for walks, throwing chapters of his books away. That is all fine and dandy, I apply that process. Minus the publishing step. The throwing away? I've got that covered.
As it happens with slow-ish writing. I am sitting down with my keyboard, rambling. [Chris Nesbitt-Smith](https://www.linkedin.com/in/cnesbittsmith) made a virtual [Jamie Dobson](https://www.linkedin.com/in/jamie-dobson/) to critique his writing. It is the only critic that encourages him to curse in his writing. As [Emmanuel Gaillot](https://codingdojo.org/people/EmmanuelGaillot/) would say: "Fuck that shit".
Jamie kindly offered Chris to critique his writing for real. And then I have this punchline, that relates to the post. But my thread was lost hunting for links. I believe it comes down to [Sitting for the blues](https://www.youtube.com/watch?v=vaxcfW-43oQ), "sand in my heart". I'll leave you with the punchline:
So I agree with Jamie. No virtual Jamie needed.
(a LinkedIn comment was written while editing this post, hunting for links. The dopamine hits are real.)
@@ -0,0 +1,88 @@
%{
title: "Coding agent generates its' own extensions",
author: "Willem van den Ende",
tags: ~w(pi.dev ai loops),
description: "Handwritten note about generating extensions for the coding agent you are in a session with on something else. Engineer solutions in the moment.",
published: true
}
---
_This post was written longhand_. Conversion to text was done with MyScript Notes, I did some minor manual edits to correct words and explain a few things (in parentheses).
I see a few people writing about sharing struggles with Large language models, saying we are all still figuring this out.
> For me it is easier to do at the moment of a small success.
Stopping a model when it goes off the rails - but automated
---
The other day I tried to develop an extension for [Pi](https://shittycodingagent.ai) (a coding agent). I want to stop a model when it goes off the rails.
I now have a local model that is fast, can call tools (search, run test, edit code etc.). It does, however, perform some model assisted coding quirks frequently:
- Replace _production code that works_ with _throwing an exception__
- Write if statements in tests
- Add fallbacks for things that can't fail
- Find "problems" in code that works (passes tests + other checks, works for the user etc)
Long term the solution probably is to work in small steps. But these steps come from experience.
Catching the problem when it happens by simply matching some words is a starting point for that: scan for key words in any edits and prompt the user for permission. Just abort when the session is not interactive.
Looks simple, so I let a more powerful but slower local model figure out how to build an extensions. Every Pi session opens with an invitation:
> Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.
After some iteration we had a plan and Pi generated a plausible looking extension.
I tested it manually, in Pi. Nothing happened. Back to the drawing board.
I had quite a few iterations, compared with sample code, looked into the Pi API.
No luck.
Eventually I installed the sample extension. That worked. Then I deleted most of my
extension, added some logging - I could see something.
I learned quite a bit about Pi and its extension mechanism.
---
It looks like only the last "UI notification" gets shown for any exension point (e.g. a tool call or system startup). I am not yet sure if this is by design or not.
I did take away that, here too, I want to work test-first for parts that do not interface
with the agent directly. The feedback loop is just too slow otherwise.
This also required experimentation. I did not want to set up a separate project for an extension that is little more than an idea. But I do want tests. So I asked a model again. The suggestion was to use Deno, because that has testing built in. Some more fiddling followed:
- Get [deno](https://deno.com/) to work in the [nono](https://nono.sh/cli) sandbox
- Learn that Pi auto loads any thing in the extensions folder. If you put a test there, Pi crashes. (the test does not have the method that defines an extension. All files in `.pi/agent/extensions` must have it.)
- Learn that "domain" files also don't work there. (I wanted to have the extension files thin and the testable functions separate. So that the tests don't depend on `pi` and its' types).
So eventually I ended up with this design
---
![handwritten draft of the paragraph below](/images/blog/2026/local-llm-handwriting.png)
```
- .pi / test
/ core
/ extensions
```
`Core` contains the functional cores, `test` tests the core. `Extensions` is a thin integration with Pi that uses the core. (the Deno project lives in )
This was clear enough that the slow, dense model could build a second extension and performance metrics in chat, with relatively little guidance after iterations on a plan.
I haven't looked at the code yet (in detail). not out of principle, but because it is late,
and I want to write down my trial and error before I forget.
Afterword
---
I hope you enjoyed this slowly written note. I have added the
[handwritten draft](/images/blog/2026/coding-agent-generates-extensions-handwritten.pdf) as pdf.
I found writing in long hand helped me slow down and step away from the slot machine that wishcraft can be sometimes.
@@ -0,0 +1,55 @@
%{
title: "A pair pomodoro with Pi",
author: "Willem van den Ende",
tags: ~w(pi.dev ai kata),
description: "A brief experiment in working in short cycles with a coding agent. Human very much in the loop.",
published: true
}
---
I am looking for better ways to work with coding agents in a group learning setting. Today I was experimenting a bit with the shortest time to next interaction. Glazing over a coding agent's output, or task switching while it is doing its thing is annoying enough when working alone, but is killing in a classroom setting.
A few things that I iterated further on today:
- Use the fastest programming language for iteration (the programming language is a means for the experiment. It is good fun and a bit different. It sometimes helps if no one, including me, is that familiar with the target)
- Prompt for small steps, and see if the agent stops for interaction
- Observe the number of turns and the time from questions to response
- Block the agent when it is taking too many turns.
Blocking the agent after a set number of turns is automatic. I made an extension for Pi, because I need this for regular work as well. Sometimes models overthink or get stuck calling tools in a loop. Counting the number of turns is a fairly simple way to address this. This exercise was a surprisingly quick way to come up with some improvements for it.
Limiting the number of turns is also a way to reduce the _time to next interaction_. The time between we give an instruction, and the agent stops, so we can steer it, calmly.
What happened
===
I wasn't planning to blog about it, hence the first point. I found with an earlier experiment that chicken scheme lets an agent complete simple tdd and refactoring exercises almost twice as fast as some other languages and runtimes.
Prompting for small steps.
---
I asked the pi coding agent to work with me on a kata. It proposed some katas, and I chose one. I asked it to work in small steps, and discuss with me. This worked fairly well. It asked me some questions, I answered, gave feedback and explained my preferences. For a classroom exercise I might prepare the first round to reflect my taste, but critiquing the agents' output also might have value.
And then... a surprise...
----
After two rounds, I realised we had not actually run the tests. So I asked the agent to run the tests. What happened next was a bit of a surprise, it took a bit longer than a few turns. Since I was doing it for my entertainment, I let it run for two minutes, in wich pi with a local model made it's own mini testing framework, because the sandbox does not allow package installation!
The mini testing framework had some errors, the model eventually matched the parentheses (it is a Lisp after all) and fixed something else. And came out with two passing tests.
In the nick of time. After 25 turns (the default) my extension blocked the model from running folder.
What next?
-----
I made an extension for Pi that limits the number of turns the agent can run. That works fairly works well. The default is set too high (25 turns) for an interactive session. I would like a widget that allows me to set the maximum number of turns in the chat, and that asks me whether to continue or not when the number of turms will be exceeded. Sometimes it is interesting to see the model continue.
On the other hand, aborting after the max number of turns will clearly show up as 'Abort' in the session transcript, so might be handy to review.
I will also let the tests fail first :-). The model correctly-ish listed steps of TDD (Red - Green - Refactor), but there is a difference between knowing and doing. So we had nothing, then a test framework, and then two passing tests.
The first number of rounds ran fast enough, so limiting the number of turns should be better.
I might keep the discussion of chicken scheme in the beginning. You can see the full
[session transcript](/images/blog/2026/string-calculator-chicken-scheme.html) . What do you make of it?
@@ -0,0 +1,137 @@
%{
title: "How to get started with the Pi coding agent (on a VPS)",
author: "Willem",
tags: ~w(pi.dev agentic-engeeringing getting-started how-to),
description: "Setting up Pi on a VPS is easier than I thought. Open source with a great unboxing experience. Follow along and enjoy!",
published: true
}
---
A few people asked me about my coding agent setup. This is a brief guide on how to set up the [Pi](pi.dev) coding agent on a VPS (virtual private server) with a hosted LLM service. This works with [Open Router](https:/openrouter.ai), and should work with anything that supports the OpenAI (ChatGPT) API, including local models, anthropic, OpenAI etc. There are other APIs available, and Pi has a great unboxing experience, as I found while writing this post.
I have used Pi for a month or two on my laptop, in a sandbox. I have Claude Code in a VPS, but also wanted Pi there. Chris Parsons (see Afterword for his blog) asked about this, so I wrote this how to for myself, and then ran and updated it. It was easier than I thought.
The idea of using a VPS (a virtual machine in the cloud) is that it provides you a sandbox to run an agent in. If the agent deletes your home folder, you can just recreate it. There are other ways to sandbox agents, but this I found by far the easiest and most comforting.
Steps in this recipe
----
1. Install Pi
2. Put your API key in an environment variable, so Pi can access it
3. Tell Pi where your LLM is hosted, and what model you want to use
4. Start Pi and enjoy
Or that is what I thought. It is simpler than that.
1. Install Pi
2. Follow the guidance and complete the installation for your model and provider in small steps.
3. `/Reload` in Pi and enjoy (*)
(*) after fixing syntax errors in `~/.pi/agent/models.json` where all of your configuration can live, unless you decide to separate it out.
I thought it was still useful to show my workings, the Pi UI is a lot more responsive than Claude code, and guides you on your way. But I did not notice that at first. I hope this helps. Have fun!
Install Pi
----
Pi assumes you have [NodeJs](https://nodejs.org/en) installed. If you don't have that, [NodeJS](https://nodejs.org/en) has instructions, it is usually in your package manager in the VPS's linux distribution.
Once you have NodeJs, run
``` sh
npm install -g @mariozechner/pi-coding-agent
```
in the terminal.
Now you can start `pi` and it will guide you to where to find the rest of the documentation. This is what it showed me:
```
Warning: No models available. Use /login to log into a provider via OAuth or API key. See:
[somewhere on your disk]/lib/node_modules/@mariozechner/pi-coding-agent/docs/providers.md
[somewhere on your disk]//node/24.0.1/lib/node_modules/@mariozechner/pi-coding-agent/docs/models.md
```
This is one of the surprising things I like best about Pi: the documentation (of the version you are using) is on your machine, and it goes out of its' way to point you and your model to the documentation, so you can figure out how to use and extend it in a conversation.
We can't have a conversation just yet. Because we have no provider, and no model.
So we need to tell Pi two things:
- what is the 'provider' (the party or server hosting your model(s))
- what models are available there
For the second point you need a bit more detail than I would like. Hence this post. I will take openrouter as _provider_ and will go there and find the cheapest model I can find - we just want to fire off a prompt and see if Pi + provider + a model can work together.
One thing you can do in Pi without a model, is use `!` to run a shell command. I'm going to run `cat` on the providers doc to see how I can set up a provider.
``` sh
!cat [..]/lib/node_modules/@mariozechner/pi-coding-agent/docs/providers.md
```
Now we can edit `$HOME/pi/agent/models.json` to set our provider endpoint and api key. You can specify models there as well, but that is not necessary to get started. `/model` in the UI lets you search provided models.
I already have set up 'pi' on another machine, so I asked it. Next section co-written with Qwen3.6:27b :
Tell Pi where your LLM is hosted (by Qwen)
---
Pi has built-in OpenRouter support. You just need to configure it in ~/.pi/agent/models.json.
Quick Setup
Create or edit ~/.pi/agent/models.json:
```json
{
"providers": {
"openrouter": {
"baseUrl": "https://openrouter.ai/api/v1",
"apiKey": "OPENROUTER_API_KEY",
"api": "openai-completions"
}
}
}
```
That's it — no models array needed. Pi loads all built-in OpenRouter models automatically. Your API key can be:
- An environment variable name (e.g., "OPENROUTER_API_KEY")
- A literal key (e.g., "sk-or-...")
- A shell command (e.g., "!op read 'op://vault/item/credential'")
After Configuring
1. Open Pi and run /model to see available OpenRouter models
2. Pick one with /model openrouter/anthropic/claude-sonnet-4 (or whatever model you want)
See the full compat.openRouterRouting options in the models docs for order, ignore, max_price, preferred_min_throughput, etc.
First prompt
----
Finally, we can issue a prompt. I learned (from Claude Code) that asking a model to write a haiku is a way to get started when you just want to see if it works. Sometimes I put my frustrations in the prompt. Not this time.
> Model: qwen3.5:35b-a3b
Me: write a haiku
(reasoning tokens:)
> The user is asking me to write a haiku. A haiku is a form of Japanese poetry with three lines following a 5-7-5 syllable pattern. Let me write one about nature, which is the traditional subject.
> Soft rain falls gently,
> Droplets kiss the green leaves now,
> Spring's breath fills the air.
I hope this works for you. Enjoy!
Afterword
----
Chris Parsons asked me what it took me to get going with [Pi](https://pi.dev), amongst other things, as we were discussing his [How I use AI to Code](https://www.chrismdp.com/coding-with-ai/). As I am getting questions on my setup and how I use it, this seemed like a good place to start. It would be nice to have some more people around me use open source agents with open weights and open source models.
@@ -0,0 +1,53 @@
%{
title: "Smaller open LLMs now work for open agents.",
author: "Willem van den Ende",
tags: ~w(AI privacy),
description: "The last ten days have seen a phase shift in quality and speed of open weight models and inference. I am still adjusting.",
published: true
}
---
(No AI was involved in this attempt at ordering my thoughts and making my work legible to others, hopefully you).
I am replacing most, if not all, of my Claude Code workflows with [pi.dev](https://pi.dev), an open source coding agent, and local LLMs running on my laptop. If you don't have the hardware, smaller models are also cheap(er) to run on hosted services like [Open Router](https://openrouter.ai). As prices of frontier models continue to rise, and subscription plans are watered down, the capability and speed of open weight and open source models continues increasing. The last month saw a shift, with a couple releases from last week (Qwen 3.6 and several inference servers implementing performance improvements - more speed, less memory) marking a step change in user experience.
I wrote "adjusting" in the description, but maybe reeling is maybe a more accurate description of what is happening. I wrote this more general note, as I plan to write some how-to's to make decoupling (coding) agents from inference hosting approachable for more people. We're all still figuring this out, but once you are up and running, you can work with your local agent to improve your tools and way of working in small steps. I started using open weights models and open source coding tools in 2024, and have on and off kept doing that alongside Claude Code. The second half of 2025 saw some strong open agents (most notable pi.dev and Open Code]), and now paired with strong, affordable models I can do serious development work with an agent from the comfort of my own laptop.
Large Language Models are making several kinds of knowledge and ways of working more accessible and shorten some feedback loops. I am not keen on depending on frontier labs who have no customer service whatsoever, and are operating on models that are financially not sustainable. A friend of mine set up an account to use Claude Code, it got shut down without explanation. This seems to be very common at the moment. The customer service consists of a form from the sound of it, and no response.
Claude Code was?
----
Before I fell into a Claude Code subscription, I used Open Router with a local coding agent (Aider), and spent maybe 25$ in half a year. Admittedly, it was occasional use and smaller bits of work. Open Router serves both open weight and frontier lab models, pricing is transparent. So you can experiment. Enterprise AI use is likely to also become more price sensitive - it is easy to burn through a year's worth of AI expense in a quarter, as some are now finding out.
I have been using Claude Code for about a year. I noticed last week I started talking in the past tense about it. I hesitated writing a clickbait title: "Claude code was?". Late november marked a step change in how well claude code worked - the release of the Opus 4.5 model combined with Anthropics' long running agents paper made that I could brainstorm an idea for a fairly complicated web app iteratively, and then fairly easily and reliably build it. Giving me time to do exploratory testing and focus on user experience.
That also gave me some anxiety. What if my account got pulled for whatever reason? It was clear from the start of the monthly subscriptions (to me and many others. not everyone apparently) that this was not sustainable, and that at some point they would have to raise prices. Anthropic is now putting more and more of their stuff behind per-token pricing. And their models are expensive.
If you have to prompt precisely, you can just as well use a smaller model
---
At the same time, Opus 4.7 requires more explicit prompting. Probably to compete with OpenAIs models. "Creativity" is nice when you are doing an architectural spike, but less so when you try to do some precise work in a larger codebase. Small open weight models (at least until last week) also require(d?) more precise prompting. Why would you pay for an expensive frontier model + closed source harness when you have to be precise in your prompts? You might as well do the same with a local model.
I ran a small experiment last month with a model that is a lot smaller than what I usually use (4Billion and 9B parameters) and ran an auto-improvement loop on the prompt. If you know what the outcome should be, A larger model can iteratively generate a more detailed prompt that works for smaller models. Since everything runs on my own laptops, and I have cheap green energy at night, I can use this to become more independent of frontier labs, and help others. Note that I did that once, because the larger models now need much less RAM than they did a month ago. So I can keep one running in the background and whenever I have a question or something do build I will just prompt it from my writing environment (editor) or my coding agent.
Rough Timeline
----
Open weight models already gradually replaced frontier models over time. In late 2024 some of my stack overflow questions could be done by local models. Last year I bought a refurbished macbook (I try to keep the embedded carbon down). Then in the second half of last year, more and more code in mainstream languages started to work, and small things in niche programming languages. In January I could iterate on event storms locally, and get structured output out. In March I could do code analysis on a 500Kloc legacy csharp codebase without much explicit prompting. Last week I managed to develop extensions for [a local coding agent](https://pi.dev), This week I went all in and ported my favourite "skills" over to it. Some of that porting involved Claude Code, but quite a lot of it was done using the coding agent itself and local models.
Why now?
---
What changed last week was that some open weight models models now run faster on my laptop than Claude Code does, while the quality gap keeps shrinking. Speed has a quality all of its' own. The "Time To Next Response" doesn't really matter when you run a job overnight. It _does_ matter when you try to create the job, or iterate on it. I like fast feedback, being able to steer my work quickly. I think a lot of the "I run ten agent sessions in parallel" is just a side effect of inference being throttled - massive models make that more or less unavoidable because of their hardware footprint.
I hoped last year that open weight and open source models would improve. The dynamics are there: small models are cheaper to train (or larger models that are composed of many small models - Mixture of Expert models are now common and getting better), cheaper to run. Cheaper, and often also faster. You an see this if you go to Open Router and look at the TPS (tokens per second) for hosted mixture of experts models and 'dense' models (compare a Qqwen 27B with a 35B Mixture of Experts model). Cheaper and faster means more organizations and individuals can participate. Some open weight model releases were not that great. But that doesn't matter as long as a few of them are. The community figures out what models work well for what use cases, and which inference servers work well on what hardware.
Where to start?
----
You can try models in the browser on Open Router and Hugging Face, or install "Google Edge" or another app on your phone: that will run a small model right on your phone. And you can see what they are like. Then you can hook up an API (say Open Router, but also can be a frontier model from Anthropic, Google or OpenAI) to a local coding agent. OpenCode is easy to set up out of the box, and comes with a free cloud model, so you are good to go. I didn't like that, because it defaults to that free cloud model if you make a typo in the local, private model you want. I use [Pi.dev](https://pi.dev) at the moment, which is more minimal and "fail fast". I'll write some more "how to posts", because people ask me what my setup looks like.
I had to get the "where I'm at" post out first, apparently. Where are you at?
@@ -0,0 +1,290 @@
%{
title: "My local agentic dev setup today",
author: "Willem van den Ende",
tags: ~w(pi.dev llamacpp mlx ai),
description: "Yesterday my LinkedIn post about cancelling my Claude Code Max plan went viral. People asked me about my local LLM setup. A quick post about what I am using today",
published: true
}
---
I was planning to write about my local development setup at my leisure. Moving this forward as my [post on LinkedIn](https://www.linkedin.com/posts/willemvandenende_cancelled-my-claude-code-plan-was-using-activity-7454883528661676032-KdZp) the other day about cancelling my Claude Max $100 plan and going local raised a lot more interest and questions than I expected. This post attempts to answer the question: What hardware do you run, what software do you use (inference server and coding agent), and which models do you use?. I have put links to blog posts that may answer some of the other questions in the Further Reading section.
My setup works for me, I am running this on a refurbished MacBook Pro M3 Max with 64GB of RAM. note that LLMs have gotten more performant per unit of hardware and per watt by orders of magnitude over the last couple of years, and there is no end in sight yet. Over the last month my local models have gotten about 2x as fast, while the same or better capability uses less RAM. I can keep a browser open now while running a coding agent ;-). Both models explained below I can just keep running as I go about my day (one model at a time).
TLDR: I run models with [llama.cpp](https://github.com/ggml-org/llama.cpp). I have a script that pulls and builds the latest llamacpp, because I want to try the latest open weights and open source models. Also the last couple of weeks are seeing almost daily performance improvements, and I like fast feedback. As a coding agent I use [Pi.dev](https://pi.dev), and for chat, questions, brainstorming about writing I use [GPTEL](https://github.com/karthink/gptel) in Emacs.
You may note the absence of an IDE in the above. I was an early adopter of eXtreme Programming. If I can write tests first, run them fast, and refactor, I am happy. I rarely need a debugger. I still have a Jetbrains Ultimate subscription, but that is more for technical coaching work than day to day work. LLMs allow me to do refactorings and make refactoring tools on the fly for languages like Elixir that are generally not supported by IDEs anyway.
Assumptions
- We are all figuring this out.
- Quality of a harness (coding agent + "skills" + extensions) can matter as least as much as the model
- Running open models and an open coding agent + custom extensions takes time, but pays off in understanding and a stable base where engineering effort compounds
- Open, local, models have (for me) crossed the point where they are good enough for daily work with a coding agent.
As Patrick Debois noted, mine is a power users' setup. There are other ways to achieve similar goals. Some interesting ones are in the comments on the LinkedIn post, and a surprising one in the Afterword below.
In general it comes down to: more out of the box experience with something like Claude Code, Codex or OpenCode versus more control, personalisation, digital autonomy and data privacy with more of your own harness and local LLMs or hosted ones with strong privacy guarantees.
I made the table below in conversation with the 27B model mentioned below.
![comparison table between claude and Pi, some more explanation is in the further reading section](/images/blog/2026/claude-pi-comparison.png)
# Inference: LLamaCPP
I got started with `ollama` and it looks like Unsloth Studio is promising (but has no Mac hardware acceleration yet as far as I know. it is coming). I run llama.cpp because it is quite fast, and more importantly, stable on MacOs. Model makers often support llamacpp in getting changes in to get their models out - the way inference works is still evolving, quite rapidly. It doesn't matter as much for use in chat, but coding agents use 'tool calls' (xml or json the model emits to request e.g. an `ls` invocation in bash, or an 'edit' with parameters in JSON), and that is not easy to make reliable.
I try `mlx` - mac native inference, occasionally, because sometimes it is faster than llamacpp. Often works for chat, but less so for agentic coding.
I have used claude code to get me set up in the past. The [llama.cpp](https://github.com/ggml-org/llama.cpp) has good instructions on how to install and download models. If you want to ground yourself that is probably a better way to start than a prompt.
I have cloned the `llama.cpp` repository, inside my `llama-server-scripts` directory. Also a git repository. I have just put `llama.cpp` in `.gitignore`. I then had claude make a script to pull and build llama.cpp. This generally works :-). I normally install releases from everything, but I find it hard to wait when promising new models or performance optimisations come out.
I have skipped the instructions for how to install a compiler etc, This [Field report: coding with Qwen 3.6 35B-A3B on an M2 Macbook Pro with 32GB RAM ](https://www.reddit.com/r/LocalLLaMA/comments/1svdep5/field_report_coding_with_qwen_36_35ba3b_on_an_m2) also has instructions on how to set up xcode-build etc. As well as some tasks the 35B didnt do so well initially.
```bash
➜ llama-server-scripts git:(main) ✗ cat build_llama.sh
#!/usr/bin/env bash
set -euo pipefail
LLAMA_DIR="llama.cpp"
if [ ! -d "$LLAMA_DIR" ]; then
echo "Error: $LLAMA_DIR directory not found. Clone it first." >&2
exit 1
fi
cd "$LLAMA_DIR"
echo "Pulling latest llama.cpp..."
git pull
echo "Configuring CMake build..."
cmake -B build \
-DCMAKE_BUILD_TYPE=Release \
-DGGML_METAL_EMBED_LIBRARY=ON
echo "Building..."
cmake --build build --config Release -j"$(sysctl -n hw.ncpu)"
echo ""
echo "Build complete."
./build/bin/llama-server --version
```
# Models : Qwen3.6 , 35B-A3B and 27B
These came out in the last two weeks. I ran the 3.5 models before, and they were good enough to tinker with over the easter holiday. 35B is a Mixture of Experts (MoE) model. While these cost more memory, they run much faster on a Mac, or on a machine like my Framework laptop where the whole model does not fit in the GPU - for each token only 3B parameters are active, against 27B parameters for the 'dense' model. The dense model can be more cohesive. In 3.5 the difference was notable in planning and summarisation, 27B is more detailed. But here too, "good enough" counts - the 35B model is often good enough for what I do, and runs much faster. Between 30 and 80 tokens per second as far as I can tell, 27B peaks out at 19 tokens per second at the moment. This makes a big difference when I'm having a chat, less so when I run it in the background while doing something else.
Unsloth has good documentation and set up scripts for both models [qwen3.6 at unsloth](https://unsloth.ai/docs/models/qwen3.6). The parameters in llama.cpp scripts may look intimidating at first, but I got used to it by starting somewhere and modifying as I saw things come in on https::/reddit/r/LocalLlama.
Note that you don't need a script to start, there is a 'router' script that will start llamacpp, and then via the web UI (which is quite nice now), you can choose which model(s) to load. Often good enough.
Once you have downloaded a model, go to port 8000 and you cna play with it. I quite the chat as it also has conversation forking built in, and shows performance metrics as it runs. This gives me a feel for how a particular model is doing without going into detailed evals.
I will document the 27B model first, as that configuration is the cleanest on my machine. # 27B
I will start with the 27B script, because that is more copy-paste from Unsloth, and then modify to taste. Easier to follow along, hopefully.
Today's special is `--spec-default` . I couldn't even find documentation for it, so here is a [deepwiki query](https://deepwiki.com/search/what-does-specdefault-do_93cffa03-5266-4c9e-ba6a-331d12efd6db?mode=fast). It sets some default parameters for speculative decoding. This meant that the 27B model now goes over 20 tokens per second sometimes. Not blazing fast, but more comfortable for planning and it can do work in the background and finish inside Pi's timeout limits.
`-c 65536 \ ` sets the context size to 65K tokens. Since I mostly use this model for planning, asking questions etc, I don't need 256K tokens. I am of the 'reset early, reset often' school. Small, focused context for focused results. Contexts have become much less RAM consuming over the last two months, but starting a server.
`--chat-template-kwargs '{"preserve_thinking": true}' \` Keeps the reasoning traces. This means that the context in a multi-turn conversation is much easier to cache (all the same tokens come back), and some models perform better when they see reasoning tokens from previous turns (or so I heard). Since inference is relatively slow on a mac, effective caching makes a _big_ difference.
`-np 1` - only 1 process at a time. The GPU is already maxed out when a coding agent runs, I can also only single task, and additional processes is additional contexts, and I don't have that much RAM.
`--jinja` is for templating. The other ones are generating parameters that I probably copied from the unsloth huggingface page.
`-hf` will download the model from huggingface. `unsloth/Qwen3.6-27B-GGUF:Q4_K_M` is the name and the quantization of the model. I haven't done an extensive study yet as to which would be the best one for my machine. This roughly matches the default for `rapid-mlx`, so I have some comparison. Q4 means '4 bits integer'. Values are approximated through a Quantization process. You may lose accuracy, but the Qwen 3.6 models seem to be less sensitive to that. Smaller is generally faster to run, and costs less memory.
The other parameters are mostly general inference parameters, see the model page for options.
`run27b.sh`:
```bash
exec ./llama.cpp/build/bin/llama-server \
-hf unsloth/Qwen3.6-27B-GGUF:Q4_K_M \
--spec-default \
--no-mmproj \
--fit on \
-np 1 \
-c 65536 \
--cache-ram 4096 -ctxcp 2 \
--jinja \
--temp 0.6 \
--top-p 0.95 \
--top-k 20 \
--min-p 0.0 \
--presence-penalty 0.0 \
--repeat-penalty 1.0 \
--reasoning on \
--chat-template-kwargs '{"preserve_thinking": true}' \
--host 0.0.0.0 \
--port 8000
```
# 35 B
This has been my daily driver since the second half of last week. I downloaded this by hand. Full model name is probably `unsloth/Qwen3.6-35B-A3B-MXFP4_MOE.gguf`.
This one was downloaded by hand and follows an older pattern. I used Simon Willisons 'llm' tool to get started. That saves the models in a different place than `-hf` (which came later).
Here also I am running a smaller quantisation, to see if it works. Apparently not the best, but the last couple of days it has worked for me. Benchmarks came out after I started using it. There is a time for tinkering and a time for making small tools, tinkering will come back at some point.
note that this runs with a much larger context: `-c` indicates 256 K tokens. This is an area where small open models are following quite closely on the frontier. Cohesion is a different matter, but this model seems quite happy above 100K tokens. Makes improvising more relaxed. Here also `--spec-default` since yesterday. 35b model last month was running at about 30 tokens per second, now I often see well above 60 with this and other optimizations. It doesn't mean everything, (if you have to run the same prompt 3 times to get a result for instance), but iterating on a prompt is more enjoyable this way.
```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LLAMA_DIR="${ROOT_DIR}/llama.cpp"
MAIN="$HOME/Library/Application Support/io.datasette.llm/gguf/models/Qwen3.6-35B-A3B-MXFP4_MOE.gguf"
ls "${MAIN}"
exec "${LLAMA_DIR}/build/bin/llama-server" \
-m "$MAIN" \
--spec-default \
-c 262144 \
--temp 0.6 --top-k 20 --top-p 0.95 --repeat-penalty 1.0 \
--presence-penalty 0.0 \
--chat-template-kwargs '{"preserve_thinking": true}' \
--parallel 1 \
--jinja \
--host 0.0.0.0 --port 8000
```
# Sandbox: Nono
Never run a coding agent outside a sandbox. That goes for claude code, that has some blocks, but is open to interpretation, and even more for Pi (see below), that runs in You Only Live Once Mode by design. Unless you want your home directory to be deleted for instance.
I use Nono on my mac instead of Docker or devcontainers (also Docker). On Mac Docker needs to run in a VM, that costs me about 9GB of RAM that I needed for LLMs. I also can reuse installed packages over projects, and since I do many experiments, that makes it more fluid.
The sandbox has access to a number of directories on the host, and the current working directory. Nono prompts for permission, you can override that with the `--allow-cwd` flag.
Invocation:
```
nono run --profile pi -- pi
```
Configuration in `$HOME/.config/nono/profiles/pi.json`
```json
{
"meta": {
"name": "pi",
"version": "1.0.0",
"description": "Auto-generated profile for pi"
},
"filesystem": {
"allow": [
"$HOME/Library/caches/elixir_make",
"$HOME/.hex",
"$HOME/.local/share/mise/",
"$HOME/.local/state/mise/",
"$HOME/Library/Caches/mise/",
"$HOME/Library/Caches/deno",
"$HOME/.pi/agent/",
"$HOME/dev/spikes/llm/monotonic-pi-extensions/packages/",
"$HOME/Users/willem/.config/git/"
],
"read": [
],
"read_file": [
"$HOME/.gitconfig"
],
"write": []
},
"network": {
"block": false
},
"workdir": {
"access": "readwrite"
}
}
```
# Coding agent: Pi.dev
I covered the general setup with a hosted model in [how to get started with the pi coding agent on a vps](/blog/engineering/how-to-get-started-with-the-pi-coding-agent-on-a-vps) the other day. Pi will point you to the installation documentation as soon as you start it.
Configuration in `~/.pi/agent/models.json`:
```json
{
"providers": {
"llama.cpp": {
"baseUrl": "http://127.0.0.1:8000/v1",
"api": "openai-completions",
"apiKey": "dummy",
"models": [
{
"id": "Qwen3.6-35B-A3B-MXFP4_MOE.gguf",
"name": "Qwen3.6-35B",
"reasoning": true,
"input": ["text"],
"compat": {
"thinkingFormat": "qwen-chat-template"
},
"contextWindow": 262144,
"maxTokens": 32768,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "unsloth/Qwen3.6-27B-GGUF:Q4_K_M",
"name": "Qwen3.6-27B",
"reasoning": true,
"input": ["text"],
"compat": {
"thinkingFormat": "qwen-chat-template"
},
"contextWindow": 262144,
"maxTokens": 32768,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
}]
}
}
}
```
This is an excerpt. Notice that the contextWindow for 27B does not match the setting in the run script. There is duplciation here. The OpenAI API that I use here does not export the context window as far as I have found. So you specify this twice, which is annoyting. It would be nice to be able to specify this per request, as not every request needs a massive context. Suggestions welcome!
Further reading
---
- [Coding agent generates its' own extensions](/blog/engineering/coding-agent-generates-its-own-extensions) — Engineer solutions in the moment for the agent you're in a session with.
- [A pair pomodoro with Pi](/blog/engineering/a-pair-pomodoro-with-pi) — A brief experiment in working in short cycles with a coding agent.
- [How to get started with the Pi coding agent (on a VPS)](/blog/engineering/how-to-get-started-with-the-pi-coding-agent-on-a-vps) — Setting up Pi on a VPS is easier than I thought.
- [Smaller open LLMs now work for open agents](/blog/engineering/smaller-open-llms-now-work-for-open-agents) — A phase shift in quality and speed of open weight models and inference.
- Nate B Jones had a good podcast / video this week on Apples' play with local models for e.g. legal offices who can not get their work certified if their data leaves the office, no matter how encrypted it is. [Nate B Jones on Apple and the next trillion dollars](https://podcasts.apple.com/gb/podcast/ai-news-strategy-daily-with-nate-b-jones/id1877109372?i=1000763732500)0
Full disclosure: I am long APPL, NVDA and BABA (AliBaba, makers of QWEN). I use other hardware and models too and don't have much Nvidia hardware.
This [Field report: coding with Qwen 3.6 35B-A3B on an M2 Macbook Pro with 32GB RAM ](https://www.reddit.com/r/LocalLLaMA/comments/1svdep5/field_report_coding_with_qwen_36_35ba3b_on_an_m2) I have mentioned before has detailed instructions, as well as some development tasks explained in detail.
Afterword
----
What was remarkable to me in the Field Report above is that the writer chose OpenCode and Qwen3.6 35B over Claude:
> Why don't I just use [..] Claude Code? I had problems due to a lack of optimization for small context windows. Long-running tasks that complete large projects independently matter for me, so no Claude Code.
So maybe my intuition is right. I am not doing long running tasks like I was doing with Cladue Code _yet_ with Pi and Qwen. This shows it is possible, and might even be better. Detailed prompts and deterministic harnesses make the difference between frontier models and harnesses smaller. More about that maybe later. I hope this helps you, let me know if you have any questions or remarks.
Acknowledgements
----
Thanks to [Barney Dellar](https://www.linkedin.com/in/barney-dellar-88487b83/) for reporting that the links to my blog in Further Reading were broken. That was the only part I used AI for in this post...
@@ -0,0 +1,22 @@
%{
title: "Breaking work down with Yaks",
author: "Willem van den Ende",
tags: ~w(),
description: "",
published: false
}
---
Chris Parsons asked me what it took to get started with Pi. To be honest, I can't tell really, even when I think I work slowly, many things happen at once. Yesterday I did what felt like a tiny amount of work, while doing other things than development. When I sat down this morning to take notes, I already came out at multiple blogposts. I guess I am going to draft them all, and see what makes for a useful sequence.
Yesterday I loaded up the dense model, thinking "this can go slowly, I need to do other things. So if Pi does its' thing while I get some code out, that would be a win". And it was. Except now that it almost runs fast enough, I had some interactions, did some thinking and set up Pi on a second, eh, third machine.
At least setting it up again helps me answer Chris' question in part. Simplicity is when there is nothing left to take away. So I start from a blank canvas, and pull in models, stored prompts (e.g. commands, agent skills) as needed. I saw a message from *the agentic dev team person* yesterday saying that his commands etc are per project.
I had a relatively minimal Claude Code setup already. Claude is a bigger harness.
# What
# So What
# Now What
@@ -0,0 +1,27 @@
%{
title: "AI Zombies",
author: "Willem van den Ende",
tags: ~w(AI Reflection),
description: "In which the writer reflects on believing AI is conscious",
published: true
}
---
I wrote this on LinkedIn, reposting Stuart-Winter-Tear:
I've seen very smart people see patterns where there are none. I like my systems as much as the next person, and LLMs can achieve much more than I'd expect from next token prediction. But this requires a proper harness and people operating it with both feet on the ground (and not their head in a methane powered cloud, but I digress ;-) ). Stuart Winter-Tear's short piece is well worth reading. "[..]our confidence in recognising consciousness rests on shakier ground than we like to admit."
It may look conscious to you, but it is still a next token predictor in a harness.
How can you prevent falling into this?
----
There is no failsafe recipe, I am afraid. One thing that can help is a rule of thumb I found in Matteo Vaccari's blog:
Read all the markdown that you adopt.
This is also a great way to reduce the number of things to try out. I've seen some repositories with a lot of markdown, and a general prompt, and then you can put your question or your writing to an agent to get perspectives.
There usually is too much text in these repositories, so I give up reading and put the repository aside. I want skill augmentation, not cognitive offloading.
@@ -0,0 +1,86 @@
%{
title: "Generate an init script to get you(r team) up and running",
author: "Willem van den Ende",
tags: ~w(pi.dev AI deterministic continuous-delivery),
description: "Checking out a repository on a fresh machine often involves some fiddling around. An init scripts costs only one prompt and gets you going now, and months later.",
published: true
}
---
I like to hit the ground running when starting work in a repository. And I want my colleagues to get up and running quickly as well.
One thing I picked up from Anthropics [Harnesses for long running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) last November was creating an ~init~ script that sets everything up in a runnable and testable state.
Run the script, get to work. It only costs one prompt, once, to make, and every time after that is deterministic and fast.
It is one of those things where I go: "Why didn't I think of that before?". And an un-metered, local, model is powerful enough to do it while I do something else. It only took two minutes, so not too bad.
## Why make an init script for Firehose (this blog) now?
The best time to make an init script is when you start the project. The second best time is now.
I had Firehose running on another machine already. Running one command is usually enough to set up a Phoenix Liveview project, but I had just added another javascript dependency that was not covered by that. So instead of adding that by hand, and figuring out how to do that, I improved the process by adding the init script now.
I know now that it pays off very quickly. I had just used another project that had an init script on a new machine. No need to remember, it just works. I like software that "just works".
It is very satisfying when it runs, as you get an overview of next steps you can take. I like 'micro prompts' like these. And it paid of immediately, I will explain after the screenshot.
![Screenshot of a successful run. Shows 157 tests passed, useful make commands, how to start the dev server, what the demo user / password is and the port on which it runs](/images/blog/2026/init-script-result.png)
When I started the server, the port was already taken. As the script outputs the PORT variable, I knew immediately what to do, set the port and got to work.
Aside: starting the server is now muscle memory for me, and standard in Phoenix Liveview, no need to script, although the generated init script does have a `--server` option for those who don't know. Executable documentation for the win.
This was one fairly lazy prompt:
> I think it would be handy to have an init script for this repository that also downloads the js dependencies. the init.sh script should get the dependencies, run the mix setup so there is a working db, run the tests and download the js assets. the goal
> is for an agent or a person to be able to start working. At the end of the script, print handy instructions e.g. to run make, and what PORT the server will run on
It only took this prompt. It did take quite a few turns. My Pi turn-limit extension alerted me that it had gone over 25 turns. I chose not to let Pi run further. What happened was that the agent was done, but it did not have permissions to actually run the init script because the sandbox did not allow everything in it. So I ran that by hand outside the sandbox.
Do you have init scripts or something like it? Would it be useful to you? What do you think?
## Further reading
A Full ;-), [very brief transcript](`/images/blog/2026/transcripts/make-init-script.html`) of the session, so you can see all the turns taken in detail.
I wrote earlier on the QWAN blog about working deterministically where possible, and the pay-offs in [Stochastic to Deterministic](https://www.qwan.eu/2025/12/11/stochastic-to-deterministic.html). Even though my local model is un-metered, running a script is still a lot faster and much more predictable.
I used the 35B model from [my local agentic dev setup](/blog/engineering/my-local-agentic-dev-setup-today) It was more than up to the job. Sandboxing is a bit of work though.
## Bonus item - what the init script does, and local agent performance
According to the agent:
1. Pre-flight checks — verifies elixir, mix, node, npm, and PostgreSQL are available
2. make deps — fetches all Elixir dependencies
3. mix ecto.setup — creates the DB, runs migrations, seeds demo user
4. npm install — installs JS dependencies (highlight.js, etc.) in assets/
5. mix assets.setup && mix assets.build — installs esbuild/tailwind and bundles the JS/CSS
6. make test — runs the full test suite
It ends with a summary showing how to start the server (port 8056), demo credentials, and handy make commands.
The snippet below is from my performance widget. Token generation is not super fast, but all 32 turns ran while attending Chris Parsons' webinar. 2.5 minutes for a script that _just works_; is good enough for me.
📊 Performance: llama.cpp/Qwen3.6-35B-A3B-MXFP4-MOE.gguf
Prefill: 28,376 tokens @ 5253.8 tok/s
Generation: 5,699 tokens @ 38.8 tok/s
Combined: 34,075 tokens @ 223.5 tok/s (2.5m total)
TTFT: 5401ms
Turns: 32
## Things don't always go to plan
I am integrating the Microprints library that I open sourced earlier - to keep an eye on changes. My local model seems to have trouble getting the correct files to render. I fixed most of the CSS issues (Claude Opus 4.6 struggled with that as well). I am still learning about how to make Phoenix Liveview applications modular, some of that can be done by reading, other learning happens the hard way.
## Further Reading
Graham Lee wrote a pattern
[Replace Vibes with Tools]( https://library.chironcodex.com/books/patterns-in-ai-augmented-software-development/page/replace-vibes-with-tools) that gives some more context to this way of working.
Back in December I wrote [Stochastic to Deterministic](https://www.qwan.eu/2025/12/11/stochastic-to-deterministic.html) in which I attempt to explain my line of thinking more generally.
@@ -0,0 +1,143 @@
%{
title: "How to let a local model interpret images in Pi.dev with llama.cpp",
author: "Willem van den Ende",
tags: ~w(),
description: "QWEN 3.6 models are multi modal, but getting that to work requires an additional file and configuration in llama.cpp and enabling images in the Pi.dev models configuration.",
published: true
}
---
I have used visual language models occasionally, but since I switched from Claude Code to [Pi](https://pi.dev) I had not gotten around to setting it up. Jon Jagger mentioned he had just tried it out in a programming session with Claude Code, and then I decided that maybe I shoult take a bit of time to figure it out. It was not that difficult, sort of in the category: "Why did I not do that sooner?". It is quite handy to be able to draw a state diagram and use that as a starting point to draw out a workflow.
In this case, we were discussing giving developers feedback on how they are running the TDD cycle in CyberDojo (e.g. many 'red' cycles may mean the step is too big), and I was also interested in making a deterministic extension for Pi to slow down coding agents and people, and make sure that the *Refactoring* step actually happens. The state diagram was a good starting point, it got more complicated when building a Pi extension. More about that in a next post.
QWEN 3.6 models are multi modal, but getting that to work requires an additional file and configuration in llama.cpp and enabling images in the Pi.dev models configuration.
In addition to the configuration I described in [My local agentic dev setup two months ago](/blog/engineering/my-local-agentic-dev-setup-today), I had to download an 'mmproj' file and specify it in my llama.cpp run script. And then it was vision capable. I put the mmproj file in the directory with my scripts, as the name is somewhat generic, and I do not yet know if it is the same for other models, or specific for this one.
There are some more changes to the script since the last time, most notable `--spec-default` for 'speculative decoding' which gave a nice speed bump.
``` bash
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LLAMA_DIR="${ROOT_DIR}/llama.cpp"
# GGUF_DIR="$(llm gguf models-dir)"
GGUF_DIR="/Users/willem/Library/Application Support/io.datasette.llm/gguf/models/"
MAIN="${GGUF_DIR}Qwen3.6-35B-A3B-MXFP4_MOE.gguf"
ls "${MAIN}"
exec "${LLAMA_DIR}/build/bin/llama-server" \
-m "$MAIN" \
--mmproj unsloth/Qwen3.6-35B-A3B-GGUF/mmproj-F16.gguf \
--spec-default \
-c 262144 \
--temp 0.6 --top-k 20 --top-p 0.95 --repeat-penalty 1.0 \
--presence-penalty 0.0 \
--chat-template-kwargs '{"preserve_thinking": true}' \
--parallel 1 \
--jinja \
--host 0.0.0.0 --port 8000
```
For Pi.dev, I needed to add "image" to the part of `models.json` that has the model configuration:
``` json
"models": [
{
"id": "Qwen3.6-35B-A3B-MXFP4_MOE.gguf",
"name": "Qwen3.6-35B",
"reasoning": true,
"input": [
"text", "image"
],
"compat": {
"thinkingFormat": "qwen-chat-template"
},
"contextWindow": 262144,
"maxTokens": 32768,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
}
]
```
That was all the configuration changes. Two lines and a copy.
I then fed a state diagram of the red-green-refactor cycle that Jon Jagger made, by tab-completing the filename in the prompt, and the model figured out it was a state diagram (this was, admittedly also in the name of the file). It also found out the states and the transitions - much faster than trying to type it out. The red green refactor cycle from tdd was recognised. TDD was in the name of the file, but the connection with red-green-refactor was not. the only thing slightly off is 'rip' instead of *ship*. Which leads to some more fun interpretations further down.
The results I found surprisingly good, as Jon sketched this during a conversation, with no intent to convert it later. Here is the photo, you can see the shadow of my phone.
![Hand drawn state diagram, transcribed below in 'Inline session transcript' as tables and part mermaid diagram.](/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
![what inline image location should be](../../../static/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
Below you can see the part of the session where QWEN interpreted the image. I asked after that to make a mermaid diagram. The mermaid syntax was broken, so either a larger or more cohesive model might be needed, or feedback from running `mmdc` - the mermaid command line.
# Inline session transcript
I have chosen a vision capable model now
Great! Now I should be able to see the image. Let me read it again:
read tdd-state-diagram-john-jagger.jpeg
Now I can see it! This is a TDD State Diagram drawn on a yellow sticky note. Here's the breakdown:
## States
| State | Description |
|------------------|----------------------------------------------------------|
| **RED** | The "fail first" state — write a failing test |
| **GREEN** | The "make it pass" state — minimal code to pass the test |
| **●** (terminal) | Accept/exit state — done, ship it |
## Transitions
| From → To | Label | Meaning |
|-----------------------------------|----------------------------|--------------------------------------------------------------|
| **RED** → **RED** (self-loop) | *"improve the diagnostic"* | Refine the failing test / understand what's wrong |
| **RED** → **GREEN** | *"pass"* | Write minimal code to make the test pass |
| **GREEN** → **RED** | *"change"* | Refactor or add new requirements (break the test on purpose) |
| **GREEN** → **GREEN** (self-loop) | *"relax"* | Refactor while tests stay green |
| **GREEN** → **●** (terminal) | *"rip"* | Ship it / done (rip the test away) |
## Flow
1. Start in **RED** — write a failing test
2. **Improve the diagnostic** (self-loop) — debug the failure
3. Make it **pass** → move to **GREEN**
4. **Change** the requirements → back to **RED**
5. **Relax** (refactor) while staying **GREEN**
6. When done, **rip** → terminal state
A classic, hand-drawn representation of the Red-Green-Refactor cycle!
## Mermaid Source
```mermaid
---
title: TDD State Diagram — John Jagger
---
stateDiagram
[*] --> RED
RED --> RED : improve the diagnostic
RED --> GREEN : pass
GREEN --> RED : change
GREEN --> GREEN : relax
GREEN --> [*] : rip
```
1. Start in RED — write a failing test
2. Improve the diagnostic (self-loop) — debug the failure
3. Make it pass → move to GREEN
4. Change the requirements → back to RED
5. Relax (refactor) while staying GREEN
6. When done, rip → terminal state
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>06-26-an-example-would-be-handy-right-now.html</title>
<meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8"/>
<link rel="stylesheet" type="text/css" media="all" href="https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.min.css" />
<link rel="stylesheet" type="text/css" media="all" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github.min.css" /><meta name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no'><style> body { box-sizing: border-box; max-width: 740px; width: 100%; margin: 40px auto; padding: 0 10px; } </style><script id='MathJax-script' async src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'></script><script src='https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js'></script><script>document.addEventListener('DOMContentLoaded', () => { document.body.classList.add('markdown-body'); document.querySelectorAll('pre[lang] > code').forEach((code) => { code.classList.add(code.parentElement.lang); }); document.querySelectorAll('pre > code').forEach((code) => { hljs.highlightBlock(code); }); });</script>
</head>
<body>
<h2 id="section">}</h2>
<p>Would I recommend Emacs as a writing environment? I dont know. It
works for me, mostly thanks to org-roam. And after 5 years of <em>thou
shalt not lose weeks of configuring Emacs</em>, I am occasionally making
small tweaks.</p>
<p>Admittedly, these tweaks sometimes are things that <em>should just
work out of the box (TM)</em>. For instance, I can drag and drop an
image (on my mac, at least) in an org-mode file, and then I get to
choose how the image should be linked to the document. And then I can
see it.</p>
<p>But <em>of course</em> I mean <em>for some reason</em> this does not
work in markdown-mode. Luckily <a
href="https://oxal.org/blog/powerful-emacs-hacks-paste-images-markdown/">someone
else solved this problem before me.</a>. And found that markdown mode is
extensible in how it looks for files.</p>
<p>But what does this have to do with shaving yaks, you wonder? Well, I
was annoyed that I could not drag and drop images, displaying images was
something the author figured out first, and used as part of a solution
in dropping images.</p>
<p>I have been muddling through, and was reminded I have that problem
too. But then I was stuck. I hate thinking about paths on my computer.
And for some silly reason, I made this blog in the same way as Jekyll
and other static site generators - the images are nowhere near the blog
posts. I should fix that, but not on a hot friday when Im recovering
from successful, but intensive, new work.</p>
<p><a
href="https://oxal.org/blog/powerful-emacs-hacks-image-markdown/">This
post helpfully explains how to find the right function, and what to
do</a>. And I have a link to an image <a
href="https://willemvandenende.com/blog/engineering/enabling-a-local-model-to-explain-images-in-pidev">in
my previous post</a> that will of course not show in
<code>markdown-mode</code>. Staring at the screen for a while, it hit
me.</p>
<p>## An example would be handy right about now</p>
<p>So I copied the image link, had a look at my directory structure,
enabled images (yes, that is disabled by default. I guess a
distraction-free writing environment requires the purity of no man-made
images). And then still saw nothing, because I forgot the
<code>static</code> part of the path. But this was quick iteration, no
code or tests were harmed in the making of this example:</p>
<pre><code>![Hand drawn state diagram, transcribed below in &#39;Inline session transcript&#39; as tables and part mermaid diagram.](/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
![what inline image location should be](../../../static/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)</code></pre>
<p>Now I can go back to the joy of copy-paste-modifying the code from
the blogpost with my very own path.</p>
<h2 id="a-test-would-be-handy-right-about-now">A test would be handy
right about now</h2>
<p>I figured I could hack this path <em>only</em> for my blog, and use a
local configuration. After copy-pasting the code, it was time, for,
<em>le sigh</em> <em>modify</em>. I could let my animatronic rubber duck
do it, but I have decided to better understand my emacs mods, so doing
it by hand today.</p>
<p>There is a <code>substring</code> call that I dont need - my example
shows that blog post and images are both under <code>priv</code>. So I
need a bunch of <code>..</code> or be clever and use the git project
root. Since my example with <code>..</code> works, well make it work
and then maybe make it right.</p>
<p>But I dont often write tests. And there was an error in it, so going
straight to an emacs lisp file was too big a step.</p>
<h2 id="taking-a-step-back-literate-programming-to-the-rescue">Taking a
step back, literate programming to the rescue</h2>
<p>In org mode I can have my function and a call for an example in a
code block, and see the error right there. I had a wrong number of
arguments somewhere. But I cant understand the
<code>ert-deftest</code> error message.</p>
<h2 id="another-step-back">Another step back</h2>
<p>Running the function in an org-mode code block worked. the function
doesnt. I needed my rubber duck. Thank you dear reader.</p>
<p>I dont know why emacs <code>ert-deftest</code> test framework needs
so many keystrokes. I need to evaluate the buffer (the place where you
edit files) and then run the test runner separately. Org-mode worked but
also this is not where the code should end up.</p>
<p>The example code in the blogpost used <code>message</code>, so I
added that to my emacs lisp file. Now I can evaluate the buffer with a
changed function, and see the output (in the messages buffer, and in
the status line below)</p>
<figure>
<img src="/images/blog/2026/tdd-state-diagram-john-jagger.jpeg"
alt="Hand drawn state diagram, transcribed below in Inline session transcript as tables and part mermaid diagram." />
<figcaption aria-hidden="true">Hand drawn state diagram, transcribed
below in Inline session transcript as tables and part mermaid
diagram.</figcaption>
</figure>
<figure>
<img
src="../../../static/images/blog/2026/tdd-state-diagram-john-jagger.jpeg"
alt="what inline image location should be" />
<figcaption aria-hidden="true">what inline image location should
be</figcaption>
</figure>
</body>
</html>
@@ -0,0 +1,100 @@
%{
title: "An example would be handy right about now - how to shave yaks with great efficiency .",
author: "Willem van den Ende",
tags: ~w(Emacs Examples),
description: "Brian Marick once distributed stickers with 'An example would be handy right about now'. This is also handy in cases where writing a test is not directly obvious",
published: true
}
---
# TLDR;
I write these blogposts in markdown. My editor does not show images, because the paths in the site are different than in the filesystem. My editor is moldable, but I only mold if I need to. I don't want to turn into "I am using tech x to write my blog" and blog about nothing but the tech I use for blogging. Since I am [writing more posts](https://xkcd.com/741/), not being able to easily work with images was starting to get to the point where investing a bit of time in working with images better was worth it.
Originally I wanted to be able to drag images into my editor and get them in the right place for the blog. And then, by reading, I realised that correctly placed images for my blog were not showing when I was editing. So I fixed that first, following someone else's learning path.
Fixing this involved staring at the wall, and then realising that an example would be handy. And it was. Even without a unit test.
# How it began
Would I recommend Emacs as a writing environment? I don't know. It works for me, mostly thanks to org-roam. And after 5 years of _thou shalt not lose weeks of configuring Emacs_, I am occasionally making small tweaks.
Admittedly, these tweaks sometimes are things that _should just work out of the box (TM)_. For instance, I can drag and drop an image (on my mac, at least) in an org-mode file, and then I get to choose how the image should be linked to the document. And then I can see it.
But _of course_ I mean _for some reason_ this does not work in markdown-mode. Luckily [someone else solved this problem before me.](https://oxal.org/blog/powerful-emacs-hacks-paste-images-markdown/). And found that markdown mode is extensible in how it looks for files.
But what does this have to do with shaving yaks, you wonder? Well, I was annoyed that I could not drag and drop images, displaying images was something the author figured out first, and used as part of a solution in dropping images.
I have been muddling through, and was reminded I have that problem too. But then I was stuck. I hate thinking about paths on my computer. And for some silly reason, I made this blog in the same way as Jekyll and other static site generators - the images are nowhere near the blog posts. I should fix that, but not on a hot friday when I'm recovering from successful, but intensive, new work.
[This post helpfully explains how to find the right function, and what to do](https://oxal.org/blog/powerful-emacs-hacks-image-markdown/). And I have a link to an image [in my previous post](https://willemvandenende.com/blog/engineering/enabling-a-local-model-to-explain-images-in-pidev) that will of course not show in `markdown-mode`. Staring at the screen for a while, it hit me.
# An example would be handy right about now
----
[Brian Marick](http://www.exampler.com/about/) handed out stickers saying "An example would be handy right about now". I can read posts all day long, but modifying someones' (generated) code is probably going to end badly, without checking the output before and after.
So I copied the image link, had a look at my directory structure, enabled images (yes, that is disabled by default. I guess a distraction-free writing environment requires the purity of no man-made images). And then still saw nothing, because I forgot the `static` part of the path. But this was quick iteration, no code or tests were harmed in the making of this example:
```
![Hand drawn state diagram, transcribed below in 'Inline session transcript' as tables and part mermaid diagram.](/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
![what inline image location should be](../../../static/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
```
Now I can go back to the joy of copy-paste-modifying the code from the blogpost with my very own path.
# A test would be handy right about now
I figured I could hack this path _only_ for my blog, and use a local configuration. After copy-pasting the code, it was time, for, _le sigh_ _modify_. I could let my animatronic rubber duck do it, but I have decided to better understand my emacs mods, so doing it by hand today.
There is a `substring` call that I don't need - my example shows that blog post and images are both under `priv`. So I need a bunch of `..` or be clever and use the git project root. Since my example with `..` works, we'll make it work and then maybe make it right.
But I don't often write tests. And there was an error in it, so going straight to an emacs lisp file was too big a step.
# Taking a step back, literate programming to the rescue?
In org mode I can have my function and a call for an example in a code block, and see the error right there. I had a 'wrong number of arguments' somewhere. But I can't understand the `ert-deftest` error message.
Another step back
==
Running the function in an org-mode code block worked. the function doesn't. I needed my rubber duck. Thank you dear reader.
I don't know why emacs' `ert-deftest` test framework needs so many keystrokes. I need to evaluate the buffer (the place where you edit files) and then run the test runner separately. Org-mode worked but also this is not where the code should end up.
The example code in the blogpost used `message`, so I added that to my emacs lisp file. Now I can evaluate the buffer with a changed function, and see the output (in the 'messages' buffer, and in the status line below)
For testing in Emacs, I put the actual image in this blogpost, until I could see it. I like this TDD state diagram, and am evolving it as I spike on
![Hand drawn state diagram, transcribed below in 'Inline session transcript' as tables and part mermaid diagram.](/images/blog/2026/tdd-state-diagram-john-jagger.jpeg)
# And then it does not integrate
The last part is monkey patching the long function that markdown-mode uses to generate the image.
the monkey patched functoin enables the 'hook' in another place, but the hook does not get called.
I am now at the point where I am putting print statements in to see what happens. Print is known as `message` in emacs. And the rubber duck of writing does its thing - after taking a break.
I put the hook definition inside a callback that gets run when the mode loads. Which of course it does not. More `modify` needed after `copy-paste`.
# It works. Will it load with my blog repository?
If I do this by hand, yes. When I add a file named [dir-locals.el](https://wiki.migueldeoleiros.com/notes/20250117203949-local_emacs_configuration_per_directory) I can redefine global variables per mode. The file itself is a list of modes, and settings for each mode. It does not allow for defining new functions.
That is annoying. With [Pi.dev](Pi.dev) it is nice that I can start [moldable development](https://medium.com/feenk/rewilding-software-engineering-900ca95ebc8c) right in my current project, and only extract to my machine or a published repository once I am a bit further and ready to generalise. I have an ugly hack in a file. I could move this ugly hack to my personal emacs repository, put the image prefix in a `.dir-locals.el` and at least it would automatically load. On my machine. I would like this to go with the blog source, so I can see images wherever I edit. I find it annoying to work on another machine and have to go and find I do not have something.
# And that is why I write blog posts like this
Some of my blogposts seem to help other people, which was the stated aim, and it is nice to see that it works. The other one is to pay it forward, I managed to improve my blog writing workflow thanks to someone else's blogpost and am paying it forward. The pay-off after a few weeks seem to be that it is easier for me to find things back in my blog than on my machine(s). I have used my [instructions for setting up Pi.dev on a VPS](https://willemvandenende.com/blog/engineering/how-to-get-started-with-the-pi-coding-agent-on-a-vps) to remember how to set up open router on one of my machines, for instance.
[Backing up your work to the internet](https://lkml.iu.edu/hypermail/linux/kernel/9607.2/0292.html) is great. I hope you enjoyed this yak shaving adventure.
The rabbit hole of emacs is deep, but removing friction from my workflows is not that onerous. And I needed to tinker with a small bit of code to recover from developing a more conceptual talk and a new workshop.
# Where is the code?
I forgot about that the first time around. For now it lives in [init.el](https://gitea.apps.sustainabledelivery.com/mostalive/firehose/src/branch/main/init.el) in the firehose repository. I want it to load when I open a markdown file in the repository. So it works on my other machines, and in the unlikely event that someone else uses firehose for their blog and uses Emacs to write posts, they should have the code to enjoy images too.
@@ -0,0 +1,29 @@
%{
title: "Removing a special cause of variation in small steps",
author: "Willem van den Ende",
tags: ~w(Emacs AI Pi.dev ),
description: "Next annoying variation in my blog writing workflow is adding images to a post. I wanted to add drag and drop. I let a coding agent generate something that almost worked, and then did the finishing touches by hand, simplifying the UX and code as I went.",
published: true
}
---
The next annoying variation in my blog writing workflow is adding images to a post. I wanted to add drag and drop. I let a coding agent generate something that almost worked, and then did the finishing touches by hand, simplifying the UX and code as I went, and learning more about writing tests and debugging in Emacs Lisp as I went.
Adding images is something I want to do in the flow, while looking at images. Thinking about where to store them is _accidental friction_ during the writing and edting process.
In [the previous post](/blog/engineering/an-example-would-be-handy-right-now) I made images visible (to me) while editing blog posts. Adding said images was still as annoying as it was in other static sites I write in. Including the QWAN slides repository. Images are not next to blog posts. I repeated that mistake here when creating Firehose. Fixing that is a bigger step. I wanted a small step, and even when images are next to blog posts, I still have to go into the terminal and copy files around. Often I want to add a screenshot, so I find the screenshot, then remember (aka look up) where images are stored, ~cp~ the image in the right place, and then make the link in markdown.
![Screenshot of Emacs, with a dropped image in a blog post in the left pane.](/images/blog/2026/drag-drop-image-emacs-markdown.png)
_Screenshot of working environment. Blog post with dropped image on the left, printed debug messages at the bottom, commit history on the right_.
I used [Pi.dev](https//pi.dev) to rummage through the scrapheap of Emacs lisp code on my machine. Programming by similarity is something LLMs can do. Since I found out recently that I can drop an image on an org-mode file and link it ("Attaching" org-mode calls that), I wanted the same in markdown. So I asked how that could work, and then had a function generated and wired into ~markdown-mode~ through a 'hook'. It almost worked, but not quite.
DeepseekV4-flash had generated a function with the usual LLM 'fallbacks'. What if the image does not exist? what if the directory does not exist?
I applied the usual 'fail fast' principle and deleted the fallbacks, replacing some of them with a call to ~error~. I am upskilling in Emacs Lisp as I go - I found a call to ~error~, looked up what it does: it throws an exception. I also wrote some tests, like in the previous blog post. More to get going. But as usual, it helped me simplify. The act of writing a test forces me to be very precise in what I want.
The generated code was, like much 'normal' Emacs Lisp from libraries very tied to the UI. I extracted a function to determine if something was an image. This was written as two variable assignments. Having worked with Haskell and Clojure, I would write code like that sometimes, and then extract to a function or a pipe later, once I understood the dependencies between the steps better. Having been on the road for the last week and now teaching others to work with coding agents in small steps, I am moving from _knowing what good looks like_ to _knowing what good feels like_. Intuition, judgement, guided by (bad ;-)) experience.
I won't bore you with the details of my code here. Suffice it to say, I have one excuse less to not publish posts. Screenshots are easily made, now they are also easy to publish. A small step etc.
@@ -0,0 +1,51 @@
%{
title: "Replace Moralistic Programming with Situational Awareness",
author: "Willem van den Ende",
tags: ~w(craft situational-awareness),
description: "I don't like what I now call Moralistic Programming. 'You are not a good developer if <you don't use my pet favourite practice>'. I see some of that resurfacing in discussions around coding agents. I value Situational Awareness over best practices and fingerpointing..",
published: true
}
---
I prefer to start where I am, or where we are, and figure things out from there. That requires Situational Awareness, being able to make sense, together, of what we are facing, where we want to go and what we have at our disposal.
This is contextual. Which is annoying, because it makes communicating online about our lived experience difficult when there are new-ish tools (like coding agents with LLMs) are available. What works in one context for one team, may not work for another, for reasons that may become clear in hindsight, or not.
One of the things I didn't like around the early Software Craft movement (especially the american instatiation of it) was what I now call "moralistic programming". "You are not a good developer if <you don't use my pet favourite practice>".
I'm seeing some of the same tendencies that were around in the early software craft movement repeated in agentic engineering.
Instead of "you're not a professional software developer if you don't write tests" we have "you're not a professional software developer if you write code by hand".
I enjoy both writing tests and wrangling (and developing) coding agents, and am always looking for better ways. When I briefly had a job in 1999, in the job interview Danny Greefhorst, and the late Gert Florijn, who would become my mentor, asked what I thought about Object Orientation. I remember saying something like "I like it, and use it, until something better comes along". They hired me anyway.
I like to put "knobs to 11" and figure out the extremes. So that I know where the limits of my tools and my thinking are. Preferably together with other people.
This brief post is the adaptation of a comment on [a LinkedIn post by Machiel Keizer-Groeneveld](https://www.linkedin.com/posts/machielkeizergroeneveld_llms-are-confident-and-that-trips-me-up-share-7478794175946309632-HIOY/)
Machiel:
>Because nothing is categorically solved, we don't need new words for software development, AI does not 'change everything' even though we are discovering where and how LLMs can assist our software dev practice. The downsides are also showing themselves clearly: LLMs have the wrong built-in intent, they either lack domain knowledge or context rot ignores it, causality is an obvious weakness, they do not evolve their understanding, ask too few questions, they are too confident and apt design requires real intelligence, not plausibility at scale.
>LLM or not, we need software that is reliable, transparent, extensible and capturing domain intent
As ever, we are finding better ways of delivering value (often, but not necessarily, with software) by doing it, and helping others do it.
Looking for something in my notes, I found John Cutler quoting Cat Hicks:
>What would it look like if we could ask, "how do I make this the absolute best environment for complex problem-solving" instead of "how do I take away all these annoying hard problems." The stuff we want to accomplish in the world is hard. But can be joyful to accomplish.
Further reading
---
Gojko Adzic recent post on how to use [Commanders' Intent](https://www.votito.com/methods/commanders-intent-statement/?tag=li2608) for feature development.
I like to combine Commanders Intent with Backbriefing, which is well described in the late Stephen Bungay's [The Art of Action](https://www.hachette.co.uk/titles/stephen-bungay/the-art-of-action/9781529376968/)
Afterword
----
I haven't written about this, but I have once shipped a system with very few tests (and no, this was not written in an "if it compiles it works" language, but PHP). It wasn't the optimal way to do it, but we managed to deliver value faster in other ways that were visible and achievable for the team. Focus and zero-defects worked without tests, but slower. Automating other manual work shrunk the release cycle more than tests would have - they would have added value after that, but then the project was over. Did I miss tests? Yes. Did I tell anyone about it? Well, maybe Rob and Marc at [QWAN](https://www.qwan.eu). And I told you now.
Everyone I worked with wanted to achieve a good outcome, and that is what counts. We can always find better ways.
@@ -0,0 +1,16 @@
%{
title: "Synthetic TDD",
author: "Willem van den Ende",
tags: ~w(Vocabulary AI TDD),
description: "Synthetic Test Driven Development is when you have a coding agent generate the tests and the code, and maybe also do the refactor step with an agent.",
published: true
}
---
I'm starting small posts under the vocabulary tag, to tease out concepts that fall out of other posts, but would be too much of an aside there. This one line could go under "Jargon", to be fair. These may evolve to larger post. I am going to try and at least leave you with a question in each :-).
*Synthetic Test Driven Development* is when you have a coding agent generate the tests and the code, and also do the refactor step(s) with an agent.".
When considering _Synthetic TDD_ it is worth considering what benefits you get from writing tests first as an activity, in a very short cycle, and what value you get from having tests as an artifact.
@@ -0,0 +1,253 @@
%{
title: "At coding agent skill to guide editing in-place",
author: "Willem van den Ende",
tags: ~w(AI coding-agent baby-steps),
description: "With an @<agent> instruction in a file, you can use your regular coding agent on small parts of your code, without modifiying your editor / IDE.",
published: false
}
---
TLDR
===
I made an agent skill that let's me one of Aider's favourite tricks: naming the agent in the code with `@aider extract the code below into a method <foo>`, so you don't have to go back and forth between a chat window and the code as much.
Aider is a coding agent that precedes claude code. It didn't have tool calls, but did have very well defined mini-flows like this.
I remember Aider picking this up on save, and executing immediately. This skill is a bit cruder, I run the command `skill:at-pi` and then it searches in recently changed files for lines annotated with `@pi` or `@Pi`.
The main benefit of an @<agent> instruction in a file, is that you can use any coding agent and any editor / IDE. I am not a fan of 'spicy autocomplete' that just starts generating text while I am trying to do some wishful thinking.
A skill like this isn't that hard to make. One prompt, and some iteration to make it work for me. Full skill is included at the bottom of this post. Read through it, and make your own. Curisous what you'll come up with.
Backstory
===
I am slowly making WeReview 2.0 production ready. It was mostly done with [Synthetic TDD](/blog/engineering/synthetic-tdd) so far. I was blown away over a year ago when I managed to do a rewrite of 80% of WeReview in 5 days calendar time, during a school holiday. WeReview is an opinionated session review system that helps focused conferences create programs fast with little fuss. This was one of those 'blown away' moments.
I have in places explicitly driven the design, like making flexible forms with the help of an Allium Spec as a separate system. Answering questions about "where are we" and "how did that flow work again?", "what do we still need to do" is sometimes more work than I like. So I have decided to generate documents showing the flow from one screen to the next as a side effect of running integration tests. This was inspired by a Matteo Vaccari's talk on TDD and AI, in which he demonstrated his customer focused tests that are not necessarily end to end, inspired by Ward Cunningham's Fit. That reminded my of Ward Cunningham's Swim system, that he made for the Eclipse Foundation. There is a paper about it that I will include in a follow up post.
My users use the application through screens, I can model the domain as much as I want, but that is what they interact with. I have cheaply made end to end tests, but they are slow and fiddly, and give me a 'moment in time'. I can render videos, but those are also not easy to follow for the key stakeholders. So a document describing steps in the flow, maybe with side steps as in Ward Cunninghams' work felt like a good idea, and reading his paper backwards it can be done in small steps.
Doing that took extending tests to output HTML in the right places. For this Pi + Qwen worked quite well, in the get it Run sense. Get it right, less so. I had some ideas on extracting a DSL, broke them down into tasks. When I do that, I tend to over-engineer. My coding agent less so, but once it worked, I had 400 lines of code for a test that was just spanning three screens.
Refactoring by hand and by Agent
----
This took 1.6 minutes, which is not particularly fast. But I was doing other things. There is refactoring tooling for Elixir, but it is not that easy to setup, I am in the process of doing it. I have paid attention to the extract setup refactoring more closely and will do the next one by hand, probably. I have done some extract functions by hand to keep a feel for Elixir, and also because it sometimes takes less time to just do it than to explain it.
Aside: when the coding agent reports the number of tests
----
Also note, that as Pi / Qwen reported success, that not all the tests have been run (there are 998 tests in total, this is an umbrella project with `make test` at the root)
> - ✅ All 739 tests pass
> - ✅ Clean compilation (no warnings)
> - ✅ Committed as 2e2086d
The rubber duck in the blog post is strong. All the tests are run, but in three rounds, as this is a small mono-repo. I am attempting to keep some things that are not specific for the domain, like flexible forms, separate, without spinning them off in to separate repos. The coding agent picked up on the last run, which has 739 tests without mentioning the other ones.
I was tempted to blame the agent for not running all tests, but they are part of a post-commit hook, so they get run deterministically, no matter what.
About the skill
====
It is probably overkill to use `git status --porcelain` a search through all files might work as well, or better. `rg` is not working in my agents' sandbox for some reason, I need to figure this out still. It is also specific to elixir in places. Also not sure if prioritization is necesary. I put it in, because when I see multiple things that need work in a piece of code, I want to annotate before I forget (this is analogous to putting test lists in code, but for refactorings too).
I had expected to get this to work in one prompt, but maybe my perfectionism got the better of me, once again. I thought do do this with sub-agents in one go (looking at the skill) and getting the context picked up besides my one line instruction, was interesting. See the *Context Resolution* section below.
Note that none of the `@pi` instructions will make it into the commit history, if you work one instruction at a time. Luckily the skill has an example:
```elixir
# @Pi: move to Swimex.Report
defp extract_fragment(full_html, selector) do
```
That already needed a re-prompt, because I had specified 'pi' in the instruction to create the skill, and used 'Pi' when trying it out. Moving a function to another module is simple, but annoying:
- Copy function over to new module.
- Import function in old module
- Make sure it is referenced correctly by all call sites
- Delete original function
The At Pi skill in full
=====
```
---
name: at-pi
description: Finds @pi comments in staged/unstaged changed files, presents them for prioritization, then executes each via a sub-agent (pi -p) with context from relevant imports. Use when you want to batch-execute developer instructions left as @pi annotations across changed files.
---
```
# @pi Skill
Scans changed git files for `@pi` annotated instructions, prioritizes them with the user, then executes each sequentially via a sub-agent with contextual file information.
## Step 1: Discover Changed Files
Run:
```bash
git status --porcelain | awk 'NR>0 {print $2}'
```
This returns staged + unstaged + untracked files (one path per line).
Filter to code files you care about: `.ex`, `.exs`, `.heex`, `.js`, `.ts`, `.py`, `.rb`, etc.
## Step 2: Scan for @pi Comments
For each changed file, scan for `@pi` annotations. Two formats are supported:
### Single-line
```elixir
# @pi fix the email validation to also reject nil
def validate_email(email), do: ...
```
Instruction = everything after `@pi ` on that same line.
### Multi-line (block comment)
```elixir
# @pi
# Fix the email validation logic here.
# It should handle: nil, empty string, and invalid format.
# See WereviewWeb.EmailValidator for reference patterns.
def validate_email(email), do: ...
```
Instruction = everything after `@pi` on the tag line (may be empty), plus all continuation lines that start with `# ` (or `// ` for JS/TS) until a blank line, end of file, or a non-annotation comment line.
Strip the leading `# `, `// `, or `; ` markers and join into one instruction string.
### Parsing approach
Use `grep -ni '@pi' <file>` to find lines with `@pi` (case-insensitive — catches `@Pi`, `@PI`, `@pi`, etc.), then read surrounding context to capture multi-line blocks.
### Context resolution (important)
When a `@pi` comment appears immediately **above** a function definition, struct, module, or code block, the instruction likely refers to **that code**. Read that code to identify its name, arity, and purpose. Infer this relationship and carry it into the sub-agent prompt.
For example:
```elixir
# @Pi: move to Swimex.Report
defp extract_fragment(full_html, selector) do
```
The intent is: move `extract_fragment/2` into the Swimex.Report module. The sub-agent must understand **what** is being referred to, not just the bare instruction text.
## Step 3: Build Instruction List
For each `@pi` comment found, build a structured entry:
```
[N] FILE:PATH:LINE "instruction text"
```
Where `[N]` is the discovery order index (1-based).
Example output:
```
Found 3 @pi annotations in changed files:
[1] wereview/apps/wereview/test/support/swimex_report.ex:82
"move the output_dir logic here to use the WEREREVIEW_TEST_REPORT_DIR env var"
[2] wereview/lib/wereview_web/email_validator.ex:15
"add regex validation for international domain names"
[3] wereview/apps/wereview_core/lib/wereview/tenants.ex:44
"handle the case where tenant slug already exists — return {:error, :duplicate_slug}"
```
## Step 4: Prioritize with User
For each instruction, **resolve its intent** before presenting:
1. If the `@pi` comment is immediately above a code block (function/struct/module), identify that code and describe what will happen.
2. Present a clear action statement, not just the raw instruction text.
Example output:
```
Found 3 @pi annotations in changed files:
[1] wereview/apps/wereview/test/support/swimex_report.ex:82
"move the output_dir logic here to use the WEREREVIEW_TEST_REPORT_DIR env var"
[2] wereview/lib/wereview_web/email_validator.ex:15
"add regex validation for international domain names"
[3] wereview/apps/wereview/test/wereview_web/live/session_submit_live_test.exs:7
→ The function extract_fragment/2 will be moved to Swimex.Report
(raw instruction: "todo move to Swimex.Report")
```
Ask the user to reorder by specifying a sequence. For example:
> Execute in this order: 3, 1, 2 (skip 2)
Or just press Enter to execute in discovery order. The user can skip any by omitting it.
## Step 5: Discover Relevant Context Files
For each instruction you are about to execute:
1. Look at the file containing the `@pi` comment
2. Extract import/require/use/include statements from that file to find relevant neighbors
3. For Elixir (`.ex` / `.exs`):
- `import X` — module being imported
- `require X` — module being required
- `use X` — module being used with callbacks
4. Resolve module names to actual file paths in the project
5. Read those files so you know what's available
Example for Elixir:
```elixir
# From wereview/lib/wereview_web/email_validator.ex
import WereviewWeb.EmailHelpers wereview/lib/wereview_web/email_helpers.ex
use WereviewCore.Validation wereview/apps/wereview_core/lib/validation.ex
```
## Step 6: Execute via Sub-Agent (Sequential)
For each instruction in the prioritized order:
1. **Build the sub-agent prompt**: Combine the original `@pi` instruction with context about nearby relevant files. Include:
- The **resolved intent** (what code/block the annotation refers to, e.g., "move function `extract_fragment/2`")
- The raw instruction text
- The file path and line number of the annotation
- A brief summary of what the relevant imported/required files provide (if you read them)
- Any other contextual info you already know about the codebase
2. **Spawn the sub-agent**:
```bash
pi -p "<enriched prompt>"
```
3. The sub-agent will modify files in place. You do NOT re-read or re-apply changes yourself — trust the sub-agent to make its changes.
## Step 7: Show Changes and Ask Confirmation
After all instructions are executed:
1. Run `git diff` to show all uncommitted changes
2. Present the diff summary to the user:
```
Changes after @pi execution:
modified: wereview/apps/wereview/test/support/swimex_report.ex
- removed unused @output_dir (already handled internally)
+ added email validation logic
modified: wereview/lib/wereview_web/email_validator.ex
+ international domain regex support
Commit these changes? (y/n)
```
3. If user says `y`: run `git add -A && git commit -m "Execute @pi annotations"`
4. If user says `n`: leave changes uncommitted for manual review
## Error Handling
- If a file cannot be read, skip it and note the error
- If no `@pi` comments are found in changed files, report: "No @pi annotations found in changed files."
- If `git status` fails (not in a git repo), stop and inform the user
- If the user cancels mid-execution, show partial results so far and exit
@@ -0,0 +1,105 @@
%{
title: "Does AI assistance support tiny open source projects?",
author: "Willem van den Ende",
tags: ~w(pi.dev OpenSource deterministic),
description: "I am making small contributions to tiny open source projects I have just started using, where I would let it be before coding agents.",
published: true
}
---
Many larger open source projects are struggling with floods of AI assisted contributions. The FT has a great piece about this by Sam Learner [Who cleans up after the vibe coding party?](https://www.ft.com/content/cec8df9e-b43b-4cd1-8feb-c07e804e8d33). But miniscule open source projects may fare better. I am now contributing to tiny open source projects while just trying them out.
In the before times, I would often move on or maybe ask a question when I had trouble installing a tool I was trying out. Say it had security issues, or something didn&rsquo;t work quite right on first try. I might fix it locally and have a play. But by the time I had finished doing the work, I would have no more energy to be social and make pull requests.
Especially if I need to fix more than one thing, and the author might not want to accept both. I am very much a trunk based development person, so I prefer to commit a stream of commits to main. But working asynchronously with people I don&rsquo;t know, more communication is required to make my changes make sense to the other party.
Example - DeepClause today
----
I am playing with [the DeepClause command line interface](https://github.com/deepclause/deepclause-sdk) , and had to run `npm audit fix` after installing. After fixing that, building it failed. So I opened [Pi](/blog/engineering/tag/pi.dev) and fixed it. 10 minutes work, two candidate Pull Requests (PR). One `main` branch with two commits.
As mentioned above, it is not something I do often. When working in client projects, the teams I work in can usually choose to stay out of the PR review circus and either just push to main, or pay lip service to the PR process.
Making two pull requests in one go
---
So how do I make two pull requests out of it? I ask [my assistant](/blog/engineering/tag/Pi.dev), learning something in the process:
> Now I have two commits. what if I wanted to make a Pull Request on github for each commit separately?
It then explained how to find the changed commits (this I knew, but saved me typing), and made the branches. If you look at the last `$`, cutting two branches is a one liner for [the LLM](/blog/engineering/tag/pi.dev)
```
1. Find your base commit (the one before both new commits):
$ cd /Users/willem/dev/spikes/llm/deepclause-sdk && git log --oneline -5
a305d5b fix: add missing FS type declaration for Emscripten filesystem interface
8edcaad fix esbuild and other vulnerabilities
8aa9234 v0.86
3f51edf v0.85
ecd708c v0.83
Took 0.0s
Thinking...
You have two commits on main:
a305d5b fix: add missing FS type declaration
8edcaad fix esbuild and other vulnerabilities
8aa9234 v0.86 ← base
To make separate PRs, create a branch from main before each commit, push to GitHub, and open PRs targeting main.
$ cd /Users/willem/dev/spikes/llm/deepclause-sdk && git branch pr/vulnerability-fix 8edcaad && git branch pr/fs-type-fix a305d5b
```
It then provided instructions, conveniently not knowing that I can not push to &rsquo;origin&rsquo; but have to fork it on github first. But that is ok.
>
>
> Here are the steps:
>
> 1. Push the branches to GitHub
>
> \`\`\`bash
> git push origin pr/vulnerability-fix
> git push origin pr/fs-type-fix
> \`\`\`
>
> 1. Open the PRs in your browser
>
> \`\`\`bash
>
> gh pr create &ndash;base main &ndash;head pr/vulnerability-fix &ndash;title &ldquo;fix: esbuild and other vulnerabilities&rdquo; &ndash;body &ldquo;Updates esbuild and other dependencies to fix vulnerabilities.&rdquo;
>
> gh pr create &ndash;base main &ndash;head pr/fs-type-fix &ndash;title "fix: add missing FS type declaration for Emscripten filesystem interface" &ndash;body "Defines FS type alias with methods used by swipl-wasm. Fixes TS2304: Cannot find name
> 'FS'."
> \`\`\`
>
> If you don&rsquo;t have the gh CLI installed, open these URLs in your browser instead:
>
> - PR 1: <https://github.com/deepclause/deepclause-sdk/compare/main...pr/vulnerability-fix>
> - PR 2: <https://github.com/deepclause/deepclause-sdk/compare/main...pr/fs-type-fix>
Now I can get my agent to solve the actual problem I had after just ~npm install~ing it.
Not making the same mistake twice
----
It did take me two projects to know to ask the question. I had a similar thing with [pi-browser](https://github.com/larsderidder/pi-browser), where the dependencies were out of date, and the extension was showing as `src` on loading, which confused me no end - I thought the extension had not loaded.
So I pushed the dependencies update, waiting for it to be accepted, which happened within a day or so, and then forgot to push the next commit. So this time I paused, asked my agent and pushed to Pull Requests. Although it is mighty tempting to &rsquo;just prompt my own fork&rsquo; into existing, the &rsquo;just&rsquo; is doing a lot of heavy lifting there. And from the looks of it, a lot of thought and tinkering has gone into this project over the last year.
So while I do enjoy prompting, I value collaboration more.
Do you contribute more now?
Further reading
---
I wrote about extracting knowledge and tools from repeating patterns in [Stochastig to Deterministic](https://www.qwan.eu/2025/12/11/stochastic-to-deterministic.html). I find DeepClause interesting, because it uses Symbolic AI (Prolog) to extract e.g. agentic loops into deterministic code. So instead of having an LLM control repetition, we can have code do the looping, e.g. over a number of tasks. Prolog supports _backtracking_, trying a number of times and falling back out of the box, and [DeepClause](https://github.com/deepclause/deepclause-sdk) uses that to reset the context for the LLM and try again.
@@ -0,0 +1,116 @@
%{
title: "Swim the Test",
author: "Willem van den Ende",
tags: ~w(ATDD ComprehensionMaxxing CollaborationMaxxing),
description: "Every application has some kind of workflow. Understanding larger workflows, making them cohesive and understandable to users and developers is non-trivial. I re-discovered Ward Cunningham's Swim the Test approach, this is the Further Reading section of future posts.",
published: true
}
---
TLDR;
----
Every application has some kind of workflow. Understanding larger workflows, making them cohesive and understandable to users and developers is non-trivial. I re-discovered Ward Cunningham's Swim the Test approach, this is the Further Reading section of future posts.
[Jon Uddell's 2008 post about Ward Cunninghams' implementation of Brian Marick's 'Visible Workings'] https://blog.jonudell.net/2008/03/04/ward-cunninghams-visible-workings/ is a succinct introduction . connecting the Visible Workings pattern to the implementation made for a workflow system on behalf of the Eclipse Foundation.
[The "Swim" System for User-Oriented Presentation for Test-Case results](https://wiki.eclipse.org/images/2/20/Swim_System_PNSQC_2007_Paper.pdf). Plain spoken Paper by Ward Cunningham, Bjorn Freeman-Benson and Karl Matthias.
> Perhaps the highest compliment we received was when one of our customers (SC) said that she
> easily understood how everything worked because it "uses her words" in the interface panels and
> documentation.
Why this post?
====
Refactoring integration tests towards *Swim the Test* turns out to be easier than to write about it. And was not as hard as I thought - it can be done in baby steps. I keep both losing the literature references, and at the same time finding more or them. So getting them out of the way should help me post a 'how' post more easily.
Swim the test
====
> Finally you can *swim the test*. Here the steps and results are laid out in a
> table. Time advances as you move down the rows, and theres a column for every
actor in the workflow.
!["Change personal address" Sequence diagram. 'Start' at the left, 'big2' and 'emo' column. big2 did some actions, and this results in 'User address changed'. Followed by some 'variations' (the swim paper in the linked post explains this). The 'emo' column has one 'emo address changed' box. Screenshot by Jon Udell](https://i0.wp.com/jonudell.net/img/visibleworking3.png?w=656)
Screenshot by Jon Udell.
[Jon Uddell's post]( https://blog.jonudell.net/2008/03/04/ward-cunninghams-visible-workings/) is a succinct introduction . connecting the Visible Workings pattern to the implementation made for a workflow system on behalf of the Eclipse Foundation.
The "Swim" paper
====
I learnt about the Swim system when Ward Cunningham ran a session about it at the first Agile Open Northwest (2006 or 2007 I think - this is a blog post, I may do the research later or it doesn't ship..)
[The "Swim" System for User-Oriented Presentation for Test-Case results](https://wiki.eclipse.org/images/2/20/Swim_System_PNSQC_2007_Paper.pdf) by Ward Cunningham
Bjorn Freeman-Benson and Karl Matthias
I only found this paper existed now that I went looking for it. It also explained (when you read it back to front) on how to get started. The workflow with swimlanes looks very sophisticated. It mentiones `run.php` further down, which is 'just' one flow with some screenshots.
The reason I was attracted to domain modelling (long before Eric Evans' 2003 DDD book) was this:
> Perhaps the highest compliment we received was when one of our customers (SC) said that she
> easily understood how everything worked because it "uses her words" in the interface panels and
> documentation.
Simplicity is achieved when there is nothing left to take away. Compare this to DDD's "Ubiquitous Language". The scare quotes are intentional. As a non-native speaker I had to look up Ubiquitous. Compare this with the paragraph above. I appreciate many of the user-focused things that come out of the DDD community, like Event Storming, but ask yourself, if a patterns introduce barriers to outsiders, or if it removes them (and when introducing barriers, is the trade-off worth it?).
>At the same time,
> our abstractions are detailed enough to be believed because we are running the real code in our
> simulations instead of bubbles and arrows diagrams. We respect the user's expertise without
> dumbing down our system or forcing them to use our terminology.
A large application is built in many small steps
=====
This looks like a lot of work, and I haven't integrated this in an application. Yet. Reworking existing integration tests to output dialogs as HTML for each step was not _that_ much work, and the rewards are instantaneous: multi-step workflows now become glanceable. And as they get longer, I can make small improvements to improve glanceability.
I am trying to draw in the users as well, let's see how this goes.
Further, further, Reading
====
This is meta - a Further Reading section in a further reading post ;-).
A harness for behaviour
----
I was reminded of the swim system by this talk of Matteo Vaccari on youtube which focuses on tests that are understandable by the stakeholders.
[A Harness for Behaviour: Ensuring Ai-generated Code Does What We Want - TDD in the Age of AI ](https://www.youtube.com/watch?v=3tdmoj35HG0)
He referenced Fit and focuses on writing tests that work as a communication medium between users, stakeholders and developers. He uses an external DSL in YAML. I am not against YAML, but someoone's remark last year that all new programming languages appear to be YAML gives me pause, whenever I see it. The main thing is when it works it works. Context is key. (not further reading: I ended up with an internal DSL and screenshots in small steps as a medium - the users don't need to be able to write tests, yet, communication about what we are building and how it works is key. In small steps - I don't believe good UX comes from large batches). [LinkedIn post on Matteo's account](https://www.linkedin.com/posts/matteovaccari_the-video-of-my-presentation-at-platmosphere-share-7478330775579586560-vSoX) in case you want to comment.
Matteo blogged about this earlier:
[Acceptance tests for AI Assisted development](https://matteo.vaccari.name/posts/acceptance-tests-for-ai-assisted-development/) on "grey box testing". Refers to [Approved Fixtures Pattern](https://lexler.github.io/augmented-coding-patterns/patterns/approved-scenarios/) and is also worth reading on its' own.
Visible Workings
---
[Visible Workings]( https://web.archive.org/web/20110202132102/http://visibleworkings.com) on archive.org, as the original site is no longer active. I like the notion of "Tinkerable Software".
>Explanatory software goes hand in hand with tinkerable software because the dedicated amateur will need explanations to do the tinkering well.
"View Source"
----
A comment in one of Jon Udell's posts about 'View Source' reminded me of the work of [Sue Smith](https://www.sue.codes/) My timebox is up, she has a great blog in which is she writes about building tools in an IDE to make software development accessible to newcomers. I remember her writing about how to replicate the 'view source' experience of early web 2.0, where you could find out how a web front-end worked by 'just' viewing the source.
[What comes after view source?](https://www.sue.codes/blog/afterviewsource/)
[Being intentional about friction](https://www.sue.codes/blog/whatfriction/) is more tangential, and inspiring. Some comment about the link to the tests in an application on one of Jon Udell's 'swim' posts may be related. That link to the tests inside an application dialog may be seen as friction, but also, as Sue puts it:
> Removing friction does not necessarily empower people
and
> Being able to choose which details are worth paying attention to is the trick.
Happy reading and watching
====
Thank you for making it to the end. I hope this inspires you to do more reading, watching, and more importantly, building.
As [Steve Freeman put it just now](https://mastodonapp.uk/@sf105/116991301858959311):
> ward and Bjorns “swimlanes” work for the Eclipse foundation was an inspiration. Its a crime against software that it was left derelict.
@@ -0,0 +1,149 @@
%{
title: "Learning to Swim test",
author: "Willem van den Ende",
tags: ~w(ATDD ComprehensionMaxxing CollaborationMaxxing SyntheticTDD Refactoring),
description: "The faster you can build, the more pressing communication with others and understanding together becomes. You can build software to promote understanding and collaboration, but where do you start? I found a foothold in the 'swim tests' paper and existing integration tests.",
published: false
}
---
I often find it easier to do and build stuff than to write about it. Getting over the 'further reading' section in [swim the test](https://willemvandenende.com/blog/engineering/swim-the-test) appears to have freed me up to write more.
I wrote with the LinkedIn comment announcing that post:
> The faster you can build, the more pressing communication with others and understanding together becomes.
A view of a foothold
====
Reading the "Swim System" paper, it is clear that you can build software to promote understanding and collaboration, but where do you start? I found a foothold in the existing integration tests, and a discussion on page 14 and 15 of the [Swim Systems paper](https://wiki.eclipse.org/images/2/20/Swim_System_PNSQC_2007_Paper.pdf).
![Screenshots. A dialog with test annotations on the left, a sequence diagram with test results on the right](/images/blog/2026/swim-asterisk-dialog.png)
The left part of the screenshot, a dialog with some steps, was something I could build. There is a description of how they abstracted the report creation so one source produces multiple targets, e.g. the dialog on the left and the swimlane view on the right. That is also something that can be factored out later, starting with a point solution.
I eventually read the whole paper front to back after skimming it and pondering it over. I also remember what Ward Cunningham mentioned when he presented this work: that tools like Fit(Nesse) and this are contextual, and these are something to be made in context, not off-the-shelf tools. So I read this paper, contrasted it with Matteo Vaccari's work, and my own experience in stakeholder and user-focused tests, and what we needed for the application I am working on.
What do we want?
====
From the swim systems paper
---
I liked:
- application dialogs as a way to explain the system
- backlinks to the tests from the dialogs in the actual application
- display of errors inline
- The swimlanes, and the way events are used
- Standard flows and variations on them, implemented and inspectable simply
I think the paper predates the Event Sourcing hype, seeing the past-tense framed things in the swimlanes
What I missed from the paper, and can now see after starting to build:
- Failing tests also displayed in the report (see the red "Trouble can't find <x>" sections on the right)
![Screenshots. Passed example on the left - title is a green bar. Failed example on the right, red heading and two 'trouble' bars. A clock and timestamp indicate when the tests were run.](/images/blog/2026/swim-asterisk-example.png)
What I wanted to do differently:
Some actions are abstract enough, eg. _login 'developer1'_ others are too UI focused for what I need. The order in which fields are filled in is often not important. Or so I thought, when writing this I have written some tests where one action makes another one (im)possible. So most of the time a whole dialog can be worked through by data, using the Test Data Builder pattern to allow for differences.
I have a test setup out of the box (Phoenix Liveview Test) that works well enough, and I guessed (correctly) that it would have primitives to export HTML for the reports as well.
So to start with, I decided to produce reports, and leave reporting on unmet expectations to regular assertions in the existing test framework.
From Matteo Vaccari's ATDD and AI talk
----
Matteo presented an external DSL. I liked the idea of a DSL, and decided to start with an internal DSL first. Elixir has good support for this, and I could drive this by extracting helpers from existing tests and making the scenarios longer so there are more steps in the workflow.
Some of the lines in Matteo's DSL I found a bit HTTP specific on seeing it, but then found that my tests made by [Synthetic TDD](/blog/engineering/synthetic-tdd) also suffered from this. At one point a response is parsed for an identifier that is used in subsequent tests.
See below in working in small steps on how I am dealing with that.
From my own failures
----
- being able to see the whole and have confidence it fits together coherently
- testing as a feedback mechanism was secondary, strangely enough
Having the screens, step by step, gave me confidence where I was, and made it easy to identify the next step. It did turn out to have value as a technical feedback mechanism earlier than expected. I had worked bottom up for part of a feature, and added the next step in a flow once the parts were done. Some bits to integrate them were missing, as evidenced by the new step. Good encourgement to work acceptance test driven the next time.
On being able to have confidence in coherence
---
The swim systems paper appealed to me, because I have trouble keeping an overview of workflows with multiple steps. This was always a challenge working in the XP way, as we very quickly can create a well-tested app with complicated flows. Keeping the whole coherent and "ready to mind" is hard. "Ready to hand" with good unit tests became very cheap, but seeing the forest and the trees was sometimes difficult.
This became worse with LLM generated systems. When I can create a prototype out of thin air in a couple of hours, with hundreds of tests, and several screens and can let a coding agent produce the demos (see previous posts) it feels great. But when someone else is interested and asks me how it fits together, that becomes a lot harder.
I was hesitant to take some of my visual collaboration tools to market, and I did not understand why. After building the first steps of workflow tests I do understand. It may look like a collaboration tool is about the sticky notes, but the magic is in the collaboration rules that are encoded in things like how groups are structured, how invitations work, what the unit of a 'tenant' is in a SaaS applications, who gets to (dis)invite who, how, when? Or in moderation: who invited this _Person_ and why?
It is great brainstorm-building a mobile first product outcomes focused board, but then adding teams and seeing bits of flow missing is annoying. These flows are necessary for any collaborative application, visual or not, and they are different, because the combination of peoples' roles and assumptions are unique for each application.
So I wanted, just for myself, to see as much of the flow as possible. End-to-End tests through browsers are now very cheap to make (I can make dozens in a day), but maintaining and running them is not. Especially when I am working on my laptop with a coding agent. Having sandboxed the agent, accessing a browser is not trivial.
The unit and integration tests I have so far do give me confidence that most things work, but not that the whole application flows hang together as I want. This was triggered by a stakeholder asking: "Where are we with <tool> 2.0?" . (more about <tool> later. previous blog posts drafts failed on digressions).
Every application has workflows. They may not appear to be core, but they shape collaboration, so they are.
Working in small steps, AI augmented where useful
=====
I had an integration test that could serve as a starting point. But the workflow only had two steps. Several things are taken care of beforehand, analogous to the `login(''developer1')` in the Swim Systems paper. At least it was a good place to start, so the process was roughly:
1. Find out how to write pages and dialogs (a part of the page) as html
2. Decide how to collect these as the test progresses
3. Turn the collected parts into a report
4. Refactor the existing test towards a DSL
5. Add more steps
6. Go to 4.
For 1. I found a forum post, and I already had some [Synthetic](/blog/engineering/2026/07-13-synthetic-tdd.md) tests. This was also a good opportunity to re-read [the documentation](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveViewTest.html). Rendering pages, components, selecting elements and getting the text back is all built in, so all we need to do is wrap it in a page and save the parts. I did consider writing the reports out as markdown, with html snippets at some point. When rendering html to a pdf, the page breaks happen in the middle of screenshots sometimes. But the PDF already lacks the styling.
I initially iterated with [Pi](/blog/engineering/tag/pi.dev) on how to collect tests. I had a fancy idea of collecting the various dialogs in a process (well supported e.g. by Elixir GenServers), then thought of doing it the unix way (write out dialogs, than `cat` them all together), and ended up collecting step outputs in a list, and rendering the list at the end. This did require re-ordering the test a bit: the `assert` has to come at the end, after creating the report.
My assumption was that a model could generate more steps, after refactoring towards a simple flow of steps. The generated tests had some noise in parsing outputs to determine inputs for the next steps. Finding the right extractions was a bit of work, and at the same time gave me a better understanding of what I wanted.
Away from the desk, away from the screen?
----
I was mostly away from my desk last week. After getting three steps working, in small steps, refactoring by hand (my local model had some trouble moving things around in to files, and I needed to learn more about how to organise tests and support code in a Phoenix Liveview project ), the basic flow is now at five steps. Pi with Deepseek v4 flash did most of that, with me doing 'yes' prompts from my phone.
![Screenshot of Pi session - featuring 'insufficient credits'](/static/images/blog/2026/pidev-step5-generated.png)
This insufficient credits message hasn't happened to me in a while. I deposited 25 USD in may last year on OpenRouter. Since then I mostly used Claude Code or local models. Being away from my desk means running Pi on a server, with deepseek. I spent about 10$ in the last month, preparing a keynote and TDD with AI workshops for Philips (hit me up if you are interested too, at QWAN we are responsibly extending our offerings, now that we have a better understanding of how to augment teams, and improve the outcomes and quality using coding agents). So probably about 1 or 2 $ on workshop preparation and 8 for the testing support and bringing a vibe TDD'ed app to production.
It is nice to be able to make progress while having friends over. I have learned to not multi-task too much, and when a question is too big to answer on my phone, I will just percolate on it. Good designs come from working on that - you have to be seen working for creativity to happen, and from stepping away from the screen and coming back with a fresh perspective.
Show me the reports
====
TODO add validation failure report, and full flow so far.
Reworking is easier than writing
====
Reworking and refactoring existing user-focusing integration tests towards this turns out to be easier than writing about it. A number of ideas collide, and I had trouble keeping track of the references. So the first blogpost about this was the "further reading" section.
And here as well, Programming as theory building works. I built towards the principles above, learnt a few things, got some value out early, and now have an idea of the next steps.
Further Reading
----
[Swim the test](https://willemvandenende.com/blog/engineering/swim-the-test) has more of the literature background, including the Sim Systems paper and what I mentioned about Matteo Vaccari.
The Swim Systems paper is well worth reading, it is as good as any of my favourite software engineering papers from the 1970s (but 30 years more recent).
Felienne on Programming as Theory Building
Refactoring to Patterns
John Nolan on explaining what happened after the fact
Machiel Groeneveld on wanting the reasoning
@@ -0,0 +1,25 @@
%{
title: "Retort",
author: "Willem van den Ende",
tags: ~w(Synthetic TDD pi.dev coding-agents evals ),
description: "At first I was bemused by Retort, Adrian Cockroft's whole stack coding agent evaluation tool. It is growing on me, and might be useful as a starting point for custom evals.",
published: true
}
---
I was bemused by Adrian Cockrofts' [Retort](https://github.com/adrianco/retort) - it looks like Claude Code mostly independently running evals on coding agent x model x target language combinations. The most recent report however, matches my anecdata. I have started on something like Retort earlier this year, but much smaller - custom tiny agent, a couple of small language models and about 10 programming languages with a number of small tasks (extract method, find code smells in a file etc). I parked it, because it looked like that would be significant work to turn into something explainable to more people. So it is great to see Retort blossoming.
The main takeaway for me from the latest one is that the number of turns taken by the agent seems to be a good predictor for speed of task completion, and Qwen 35B, that I use a lot, does as well as the better frontier models on this task (and better than some of the worse frontier models). My hypothesis (hope?) that a decent harness with a smaller model would perform well on incremental tasks seems to pan out.
Reading the readme of retort, the factors are free to label and create. It produces a number of synthetic 'blogs'. The [optimal blog](https://github.com/adrianco/retort/blob/main/optimal-blog.md) I find interesting, bedcause it compares anthropic frontier models with one model I use regularly (Qwen3.6-35B-A3B) and one that I have used successfully for legacy code analysis (Qwen3-Coder-Next 80B ).
Not all outcomes match my experience, Qwen3.6-35B-A3B does well enough with Elixir production code, although it struggles with macros and moving functions across modules. That, admittedly, is something more LLMs struggle with. Explaining that often takes more time than doing it by hand.
It would be interesting to see the same benchmarks with Deepseek V4 Flash, widely used on somewhat larger machines (128GB VRAM, or less VRAM + more RAM combinations) and cheap on Openrouter (I haven't gone over a dollar yet for a day of intensive use), and Qwen 3.6 27B (much slower in tokens per second on my Macbook pro M3 Max, but seems to get more complex tasks done than 35B. I tried it yesterday on a Docker deploy that 35B struggled with, and it fixed the two issues I had independently. Feedback loops do work, as long as the model is up to the task ).
Reading through the _optimal_ blog I found the source of my bemusement. Retort also does some BDD / TDD / ATDD and 'neutral' (no tests?) comparisons. I believe these are all [Synthetic TDD](https://willemvandenende.com/blog/engineering/synthetic-tdd). I have used coding agents (mainly Claude Code up to april) extensively to generate tests, recently with the help of Allium. Without tests coding agents go off the rails quickly. And I am lazy, as a good programmer should be, so I like having tests generated and providing feedback loops. But synthetic tests do have limitations. They carry an LLMs interpretation of your intent, and may not drive the design.
It may be that design doesn't matter everywhere equally. I am now working on some code where it does, and explaining myself in code (with some assistance) is faster and more precise than talking to an LLM.
I am not sure how that kind of intent could fit in a benchmark like Retort. What do you believe? Does intent and design in tests matter? If so, where and when?
@@ -0,0 +1,38 @@
%{
title: "Developing with open weights models only, three months in",
author: "Willem van den Ende",
tags: ~w(pi.dev open-weights ),
description: "It is a bit over three months ago that my linkedin post about cancelling my Claude Max subscription went viral. Time for an update. The first six weeks were uneventful, then I started missing some things from before, and yesterday I closed the loop.",
published: true
}
---
I blogged about [My local agentic development setup](/blog/engineering/my-local-agentic-dev-setup-today) the day after my post about cancelling my Claude Max subscription and going all in on local models went viral.
I am still using the same flexible harness - [Pi.dev](/blog/engineering/tag/pi.dev), with surprisingly few extensions (apart from yesterday, see below for prime-agent). I had expected to add web-search, but even my local models have a lot of knowledge, know how to use tools and look up urls from documentation. Enough to start building [Swim the test](/blog/engineering/swim-the-test), which is quite niche, into an existing system. I occasionally paste a hand-searched link into the prompt and that is enough.
The first six weeks were uneventful. I didn't need to create large prototypes in a day like before, that helped. I was busy preparing larger sessions (a coding dojo with 70 participants and 10 facilitators for a UK client, and a keynote about AI and TDD and a workshops on AI with TDD for Philips Image Guided Therapy). So mostly smaller things, and sometimes making small in-the-loop changes to existing code.
Then in a conversation with Chris Matts he mentioned he sketches UI on a piece of paper and then lets one of the hosted solutions (Figma or ChatGPT) create a screen out of it. I wasn't set up to do that. After that I have set up [image recognition in Pi.dev](/blog/engineering/enabling-a-local-model-to-explain-images-in-pidev). Surprisingly good. I haven't used it to generate screens, but could make that with some extensions.
In July I went back to working on a larger piece of software, and improvements to local inference engines were coming in thick and fast. I can now run Qwen3.6 27B, their dense model, at conversational speed, and my previous go to, their 35B mixture of experts model, runs a lot faster as well. For smaller, me-in-the-loop changes with tests, the 35B model works quite well. For trickier things, or larger independent runs, where the coding agent runs through a list of tasks and executes them in sub-agents one by one, the 27B model performs better. Slower per token, faster to outcome.
With [mtplx](https://github.com/youssofal/mtplx) instead of LLamaCPP I can now run the dense Qwen3.6 27B at conversational speed (was below 20 tokens per second, now often 30 at the start of a conversation). This means I can run more complex development tasks locally. If it slows down later, I'll go do something else, as long as it can finish the job independently.
As an example, it managed to find something properly obscure that Deepseek v4 flash also could find, but the 35B model could not. I am keeping that private as a benchmark. Before this, none of the MLX (mac accellerated) inference engines could handle long context (for me). Mtplx does that - speed does not matter if you can't get a good outcome.
MTPLX is also a proper consumer solution. It comes with a GUI to select models and see how it is running. It will figure out the best parameters to run a model with by running a benchmark on your machine after downloading a model, and comes with pre-selected models and quantizations. The screenshot below was after installing a smaller variation of the qwen27B model. Before this I had never seen any of them do more than 20 tokens per second. Around 30 is good enough for a conversation for me.
The screenshot below was taken when I installed a smaller version of the Qwen 27B model. It shows a few sessions tested on my laptop, and which one was found to be fastest, for me.
![Mtplx installation screenshot](/images/blog/2026/mtplx-install.png)
I still have LLamaCPP, but mtplx does well enough at the moment. I would us LLamaCPP when I want to run multiple smaller models on my mac simultaneously - mtplx so far only runs one model at a time. No problem for 35B and 27B as more than one does not really fit in 64GB (V)RaM.
Since the school holidays were starting and I am away from my desk more often, I revived my [remote environment in a virtual machine](/blog/engineering/how-to-get-started-with-the-pi-coding-agent-on-a-vps), so I could develop on my phone. I had used DeepSeek V4 Flash in part when preparing exercises. This model can be locally on slightly more VRAM than I have.I use openrouter for it. This has worked very well for adding features to existing code, doing longer running implementation sessions etc. It is cheaper to host than Qwen 3.6 27B apparently.
On Friday I spent a day with prime-agent. This is a ready-to-use Recursive Language Model adaptation of Pi. This runs instances of Pi on subtasks, and creates memories of things that could have been done better. It appears to do long running tasks without a ticket system like yaks, beads or a markdown file well enough. It is an example of not attaching new ways of working to old flows (ticket systems, version control). There may be better ways to discover. RLM appears to be one of them. I am doing `new session` less often, longer contexts seem to work well enough (although slower). As the memory built up, starting 'fresh' also is fast enough also is fast enough
[Prime-agent](https://github.com/PrimeIntellect-ai/prime-agent) means I can do what I did with claude code before, developing larger prototypes with tests in a conversation. But now with smaller open weights models. The 0731 release of deepseek-v4-flash appears to be strong. Prime-agent with feels more coherent than claude code in april. After three months of not doing larger prototypes, and developing all the things I had thought of before, I now have some new ideas. The harness is at least important as the model, and experiments in harness development are ongoing by many people.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
%{
title: "Future Test Post",
author: "Test Author",
tags: ~w(test),
description: "A post scheduled for the future"
}
---
This is a future test post.
@@ -0,0 +1,78 @@
%{
title: "Scheduled Publishing & Author Dashboard",
author: "Willem van den Ende",
tags: ~w(release features),
description: "Future-dated posts stay hidden until their publish date, authors get a dashboard to track drafts and scheduled content, and registration is locked down to invited emails only."
}
---
This post was generated by a coding agent, after using the demo skill to make a demo using Rodney and Showboat (thanks Simon Willison). I find it fun and useful to see this, gives some indication of what went in.
There is a note at the bottom about tools used and the number of phases.
# As the agent wrote:
Posts in Firehose are markdown files with a date in the filename. Until now, every published post was immediately visible. That changes today: posts with a future date are now hidden from public views until their date arrives.
This was built in a single session using an agentic dev team -- 12 issues tracked in beads, executed in three parallel phases, producing 232 tests across the blogex library and Phoenix app.
## What changed
### Future-dated posts are hidden from public views
The blog index, tag pages, RSS feeds, and Atom feeds now filter out posts where the date is after today. If you schedule a post for next Tuesday, readers won't see it until then.
![Blog index showing only past-dated posts](/images/scheduled-publishing/scheduled-blog-index.png)
But here's the key design choice: **direct URL access still works**. If you know the slug, you can view the post. This lets authors share preview links with reviewers before the publish date.
![Future post accessible by direct URL](/images/scheduled-publishing/scheduled-direct-access.png)
### Status banners for authors
When you're logged in, draft and scheduled posts show a status banner so you always know what state a post is in. Unauthenticated visitors see nothing -- no clue the post isn't "live" yet.
**Scheduled posts** show a blue banner with the target date:
![Scheduled banner showing target publish date](/images/scheduled-publishing/scheduled-banner-future.png)
**Draft posts** (unpublished) show an amber banner:
![Draft banner on unpublished post](/images/scheduled-publishing/scheduled-banner-draft.png)
### Editor dashboard
A new LiveView at `/editor/dashboard` gives authors a unified view of all non-live content across every blog. Two tabs: drafts and scheduled posts. Scheduled posts show a "days until live" countdown.
![Dashboard drafts tab](/images/scheduled-publishing/scheduled-dashboard.png)
![Dashboard scheduled tab with countdown](/images/scheduled-publishing/scheduled-dashboard-scheduled.png)
The dashboard requires authentication -- unauthenticated users are redirected to the login page.
### Authentication and registration gating
We added `mix phx.gen.auth` for session-based authentication with magic links and password login. Login and registration pages are accessible by direct URL only -- they're intentionally not linked from the public navigation.
Registration is gated to a single email via the `ALLOWED_REGISTRATION_EMAIL` environment variable. Anyone else gets a polite rejection:
![Registration rejected for non-matching email](/images/scheduled-publishing/scheduled-registration-rejected.png)
When the environment variable isn't set, registration is disabled entirely. A demo user (`demo@example.com`) is seeded in dev for local testing.
## How it was built
The feature was planned as an [Allium specification](https://github.com/your-org/allium) with surfaces, rules, and domain entities, then broken into 12 beads (issues) across three phases:
1. **Scheduled posts** (5 beads): date filtering in blogex, unfiltered direct access, feed/router verification
2. **Authentication** (3 beads): phx.gen.auth scaffolding, registration gating, dev seed
3. **Dashboard** (4 beads): post visibility helpers, unfiltered registry access, LiveView dashboard, status banners
All 12 beads were executed with parallel agentic workers in isolated git worktrees, then merged and integrated on main. The demo caught one bug (auth check using `current_user` instead of `current_scope`) which was fixed before this post.
## By the numbers
- **232 tests** passing (89 blogex + 143 Phoenix app)
- **12 beads** planned, executed, and closed
- **3 phases** run with parallel workers
- **0 compiler warnings**
@@ -0,0 +1,16 @@
%{
title: "v0.2.0 — RSS Subscribe Links",
author: "Willem van den Ende",
tags: ~w(release),
description: "RSS feed subscribe links with classic waveguide icons across blog pages, post pages, and site footer."
}
---
RSS feeds have always been available at `/api/blog/engineering/feed.xml` and `/api/blog/releases/feed.xml`, but they were never advertised. This release adds subscribe links throughout the site.
## What's new
- RSS `<link>` tags in `<head>` on all blog pages for browser/feed reader auto-discovery
- Orange RSS icon next to blog title on index pages
- RSS icon on each post page
- Subscribe section in the footer with links to both feeds

Some files were not shown because too many files have changed in this diff Show More