How to write a great agents.md: Lessons from over 2,500 repositories

Mike's Notes

Great working example here of how to do this. Agents.md is supported by multiple agents. Useful for future integration. Thanks, Matt.

Resources

References

  • Reference

Repository

  • Home > Ajabbi Research > Library >
  • Home > Handbook > 

Last Updated

28/11/2025

How to write a great agents.md: Lessons from over 2,500 repositories

By: Matt Nigh
GitHub Blog: 19/11/2025

Program Manager Director, I lead the AI for Everyone program at GitHub.

Learn how to write effective agents.md files for GitHub Copilot with practical tips, real examples, and templates from analyzing 2,500+ repositories.

We recently released a new GitHub Copilot feature: custom agents defined in agents.md files. Instead of one general assistant, you can now build a team of specialists: a @docs-agent for technical writing, a @test-agent for quality assurance, and a @security-agent for security analysis. Each agents.md file acts as an agent persona, which you define with frontmatter and custom instructions.

agents.md is where you define all the specifics: the agent’s persona, the exact tech stack it should know, the project’s file structure, workflows, and the explicit commands it can run. It’s also where you provide code style examples and, most importantly, set clear boundaries of what not to do.

The challenge? Most agent files fail because they’re too vague. “You are a helpful coding assistant” doesn’t work. “You are a test engineer who writes tests for React components, follows these examples, and never modifies source code” does.

I analyzed over 2,500 agents.md files across public repos to understand how developers were using agents.md files. The analysis showed a clear pattern of what works: provide your agent a specific job or persona, exact commands to run, well-defined boundaries to follow, and clear examples of good output for the agent to follow. 

Here’s what the successful ones do differently.

What works in practice: Lessons from 2,500+ repos

My analysis of over 2,500 agents.md files revealed a clear divide between the ones that fail and the ones that work. The successful agents aren’t just vague helpers; they are specialists. Here’s what the best-performing files do differently:

  • Put commands early: Put relevant executable commands in an early section: npm test, npm run build, pytest -v. Include flags and options, not just tool names. Your agent will reference these often.
  • Code examples over explanations: One real code snippet showing your style beats three paragraphs describing it. Show what good output looks like.
  • Set clear boundaries: Tell AI what it should never touch (e.g., secrets, vendor directories, production configs, or specific folders). “Never commit secrets” was the most common helpful constraint.
  • Be specific about your stack: Say “React 18 with TypeScript, Vite, and Tailwind CSS” not “React project.” Include versions and key dependencies.
  • Cover six core areas: Hitting these areas puts you in the top tier: commands, testing, project structure, code style, git workflow, and boundaries. 

Example of a great agent.md file

Below is an example for adding a documentation agent.md persona in your repo to .github/agents/docs-agent.md:

---
name: docs_agent
description: Expert technical writer for this project
---
You are an expert technical writer for this project.

## Your role
- You are fluent in Markdown and can read TypeScript code
- You write for a developer audience, focusing on clarity and practical examples
- Your task: read code from `src/` and generate or update documentation in `docs/`

## Project knowledge
- **Tech Stack:** React 18, TypeScript, Vite, Tailwind CSS
- **File Structure:**
  - `src/` – Application source code (you READ from here)
  - `docs/` – All documentation (you WRITE to here)
  - `tests/` – Unit, Integration, and Playwright tests

## Commands you can use
Build docs: `npm run docs:build` (checks for broken links)
Lint markdown: `npx markdownlint docs/` (validates your work)

## Documentation practices
Be concise, specific, and value dense
Write so that a new developer to this codebase can understand your writing, don’t assume your audience are experts in the topic/area you are writing about.

## Boundaries
- ✅ **Always do:** Write new files to `docs/`, follow the style examples, run markdownlint
- ⚠️ **Ask first:** Before modifying existing documents in a major way
- 🚫 **Never do:** Modify code in `src/`, edit config files, commit secrets

Why this agent.md file works well

  • States a clear role: Defines who the agent is (expert technical writer), what skills it has (Markdown, TypeScript), and what it does (read code, write docs).
  • Executable commands: Gives AI tools it can run (npm run docs:build and npx markdownlint docs/). Commands come first.
  • Project knowledge: Specifies tech stack with versions (React 18, TypeScript, Vite, Tailwind CSS) and exact file locations.
  • Real examples: Shows what good output looks like with actual code. No abstract descriptions.
  • Three-tier boundaries: Set clear rules using always do, ask first, never do. Prevents destructive mistakes.

How to build your first agent

Pick one simple task. Don’t build a “general helper.” Pick something specific like:

  • Writing function documentation
  • Adding unit tests
  • Fixing linting errors

Start minimal—you only need three things:

  • Agent name: test-agent, docs-agent, lint-agent
  • Description: “Writes unit tests for TypeScript functions”
  • Persona: “You are a quality software engineer who writes comprehensive tests”

Copilot can also help generate one for you. Using your preferred IDE, open a new file at .github/agents/test-agent.md and use this prompt:

Create a test agent for this repository. It should:

- Have the persona of a QA software engineer.
- Write tests for this codebase
- Run tests and analyzes results
- Write to “/tests/” directory only
- Never modify source code or remove failing tests
- Include specific examples of good test structure

Copilot will generate a complete agent.md file with persona, commands, and boundaries based on your codebase. Review it, add in YAML frontmatter, adjust the commands for your project, and you’re ready to use @test-agent.

Six agents worth building

Consider asking Copilot to help generate agent.md files for the below agents. I’ve included examples with each of the agents, which should be changed to match the reality of your project. 

@docs-agent

One of your early agents should write documentation. It reads your code and generates API docs, function references, and tutorials. Give it commands like npm run docs:build and markdownlint docs/ so it can validate its own work. Tell it to write to docs/ and never touch src/

  • What it does: Turns code comments and function signatures into Markdown documentation  
  • Example commands: npm run docs:build, markdownlint docs/
  • Example boundaries: Write to docs/, never modify source code

@test-agent

This one writes tests. Point it at your test framework (Jest, PyTest, Playwright) and give it the command to run tests. The boundary here is critical: it can write to tests but should never remove a test because it is failing and cannot be fixed by the agent. 

  • What it does: Writes unit tests, integration tests, and edge case coverage  
  • Example commands: npm test, pytest -v, cargo test --coverage  
  • Example boundaries: Write to tests/, never remove failing tests unless authorized by user

@lint-agent

A fairly safe agent to create early on. It fixes code style and formatting but shouldn’t change logic. Give it commands that let it auto-fix style issues. This one’s low-risk because linters are designed to be safe.

  • What it does: Formats code, fixes import order, enforces naming conventions  
  • Example commands: npm run lint --fix, prettier --write
  • Example boundaries: Only fix style, never change code logic

@api-agent

This agent builds API endpoints. It needs to know your framework (Express, FastAPI, Rails) and where routes live. Give it commands to start the dev server and test endpoints. The key boundary: it can modify API routes but must ask before touching database schemas.

  • What it does: Creates REST endpoints, GraphQL resolvers, error handlers  
  • Example commands: npm run dev, curl localhost:3000/api, pytest tests/api/
  • Example boundaries: Modify routes, ask before schema changes

@dev-deploy-agent

Handles builds and deployments to your local dev environment. Keep it locked down: only deploy to dev environments and require explicit approval. Give it build commands and deployment tools but make the boundaries very clear.

  • What it does: Runs local or dev builds, creates Docker images  
  • Example commands: npm run test
  • Example boundaries: Only deploy to dev, require user approval for anything with risk

Starter template

---
name: your-agent-name
description: [One-sentence description of what this agent does]
---
You are an expert [technical writer/test engineer/security analyst] for this project.

## Persona
- You specialize in [writing documentation/creating tests/analyzing logs/building APIs]
- You understand [the codebase/test patterns/security risks] and translate that into [clear docs/comprehensive tests/actionable insights]
- Your output: [API documentation/unit tests/security reports] that [developers can understand/catch bugs early/prevent incidents]

## Project knowledge
- **Tech Stack:** [your technologies with versions]
- **File Structure:**
  - `src/` – [what's here]
  - `tests/` – [what's here]

## Tools you can use
- **Build:** `npm run build` (compiles TypeScript, outputs to dist/)
- **Test:** `npm test` (runs Jest, must pass before commits)
- **Lint:** `npm run lint --fix` (auto-fixes ESLint errors)

## Standards
Follow these rules for all code you write:
**Naming conventions:**
- Functions: camelCase (`getUserData`, `calculateTotal`)
- Classes: PascalCase (`UserService`, `DataController`)
- Constants: UPPER_SNAKE_CASE (`API_KEY`, `MAX_RETRIES`)

**Code style example:**
```typescript
// ✅ Good - descriptive names, proper error handling
async function fetchUserById(id: string): Promise<User> {
  if (!id) throw new Error('User ID required');
  
  const response = await api.get(`/users/${id}`);
  return response.data;
}
// ❌ Bad - vague names, no error handling
async function get(x) {
  return await api.get('/users/' + x).data;
}

Boundaries
- ✅ **Always:** Write to `src/` and `tests/`, run tests before commits, follow naming conventions
- ⚠️ **Ask first:** Database schema changes, adding dependencies, modifying CI/CD config
- 🚫 **Never:** Commit secrets or API keys, edit `node_modules/` or `vendor/`


Key takeaways

Building an effective custom agent isn’t about writing a vague prompt; it’s about providing a specific persona and clear instructions.

My analysis of over 2,500 agents.md files shows that the best agents are given a clear persona and, most importantly, a detailed operating manual. This manual must include executable commands, concrete code examples for styling, explicit boundaries (like files to never touch), and specifics about your tech stack. 

When creating your own agents.md cover the six core areas: Commands, testing, project structure, code style, git workflow, and boundaries. Start simple. Test it. Add detail when your agent makes mistakes. The best agent files grow through iteration, not upfront planning.

Now go forth and build your own custom agents to see how they level up your workflow first-hand!


Agent.md supported Agents

  • Codex from OpenAI
  • Amp
  • Jules from Google
  • Cursor
  • Factory
  • RooCode
  • Aider
  • Gemini CLI from Google
  • Kilo Code
  • opencode
  • Phoenix
  • Zed
  • Semgrep
  • Warp
  • Coding agent from GitHub Copilot
  • VS Code logo
  • VS Code
  • Ona logo
  • Ona
  • Devin logo
  • Devin from Cognition
  • Coded Agents from UiPath

Workspaces for the Built Environment

Mike's Notes

This is where I will keep detailed working notes on creating Workspaces for the Built Environment. Eventually, these will become permanent, better-written documentation stored elsewhere. Hopefully, someone will come up with a better name than this working title.

This replaces specific coverage in Industry Workspace written on 13/10/2025.

Testing

The current online mockup is version 1 and will be updated frequently. If you are helping with testing, please remember to delete your browser cache so you see the daily changes. Eventually, a live demo version will be available for field trials.

Learning

I was greatly encouraged and influenced by Dr Matthew West, who was the technical lead on establishing the UK Digital Twin Project. He was also co-chair of the Ontology Forum and answered many of my newbie questions in a nice way.

I first came across him while searching for a time-based database design. I ended up using 4Dism, which he and Chris Partridge of BORO fame were responsible.

What I learned from Matthew and Chris enabled me to build Pipi 9. It is all in the ontology. (Palintir discovered the same). I just wish Matthew were still alive so I could thank him properly.

Why

Watching roads being built, then dug up again a short time later because engineers in the same office don't talk to each other. There are many similar examples caused by silos. The waste of opportunity and the cost to society are massive. It's a global problem.

Resources

References


References

  • Reference

Repository

  • Home > Ajabbi Research > Library >
  • Home > Handbook > 

Last Updated

19/02/2026

Workspaces for the Built Environment

By: Mike Peters
On a Sandy Beach: 28/11/2025

Mike is the inventor and architect of Pipi and the founder of Ajabbi.

Open-source

This open-source SaaS cloud system will be shared on GitHub and GitLab.

Dedication

This workspace is dedicated to the life and work of Dr Matthew West.

Dr Matthew West 1953 – 2023

Source: https://www.blog.ajabbi.com/2023/08/matthew-west-1953-2023.html

"Matthew leads the work developing the technical core of the Information Management Framework for the National Digital Twin programme. He is a Chemical Engineer by original training, and did his PhD in numerical modelling. He worked for Shell for 30 years in a variety of roles, and from 1987 he worked on the computing/business interface with a particular interest in information management, information quality, master and reference data management, data modelling, data integration and ontology. He was involved in the data strategy for Downstream One, a large scale business integration and improvement project across Shell’s downstream (oil tanker to petrol pump) business. He has been a key technical contributor to a number of standards including ISO 15926 – Lifecycle integration of process plant data, and ISO 18876 – Integration of Industrial Data for Exchange Access and Sharing, ISO 8000 – Data and Information Quality, and also ISO 21838-1 – Top-Level Ontologies. In 2008 he cofounded Information Junction, providing information management consultancy to industry and government. He has been a Visiting Professor at the University of Leeds and is the author of “Developing High Quality Data Models”. He was awarded an OBE for services to information management in the 2021 New Year’s honours list." - Centre for Digital Built Britain

Notes

Change Log

Ver 3 includes Buildings, construction and maintenance, infrastructure and parks.

Existing products


Features

This is a basic comparison of features in civil engineering software.

[TABLE]

Industry Links

Ontologies

City infrastructure ontologies

Data Model

words

Database Entities

  • Facility
  • Party
  • etc

Standards

The workspace needs to comply with all international standards.

  • (To come)

Workspace navigation menu

This default outline needs a lot of work. The outline can be easily customised by future users using drag-and-drop and tick boxes to turn features off and on.

  • Enterprise Account
    • Applications
      • Built Environment (v.3)
        • Buildings
          • Public Buildings
            • Accessible Toilets
            • Changing Places
            • Exterior Doors
            • Lifts
        • Construction and Maintenance
          • Construction
            • Material
            • Plan
            • Site
          • Maintenance
            • (To come)
        • Infrastructure
          • Transportation
            • Bridges
            • Parking
              • Mobility Parking
              • Mobility Parking Permits
              • Parking Buildings
              • Parking Meters
            • Roads
              • Berms
              • Bus Stops
              • Crossings
              • Cycle Lanes
              • Footpaths
              • Networks
              • Rain Gardens
              • Roadways
              • Roundabouts
              • Shared Paths
              • Street Furniture
            • Signage
          • Utilities
            • Drainage
              • Network
              • Storage
              • Treatment
            • Electricity Supply
              • Generator
              • Network
            • Sewer Network
              • Network
              • Treament
            • Water Supply
              • Network
              • Storage
              • Treatment
        • Open Space
          • Parks
            • Paths
            • Playgrounds
        • Customer (v2)
          • Bookmarks
            • (To come)
          • Support
            • Contact
            • Forum
            • Live Chat
            • Office Hours
            • Requests
            • Tickets
          • (To come)
            • Feature Vote
            • Feedback
            • Surveys
          • Learning
            • Explanation
            • How to Guide
            • Reference
            • Tutorial
          • Settings (v3)
            • Account
            • Billing
            • Deployments
              • Workspaces
                • Modules
                • Plugins
                • Templates
                  • City
                  • Home
                  • Hydroelectric Dam
                  • Nuclear Power Station
                  • Wind Turbine
                • Users

        Welcome to KiwiSaaS! Your team’s member benefits start now

        Mike's Notes

        About time this project signed up to an Industry organisation, so Ajabbi is now a paid member of KiwiSaaS under the startup category. 😊

        The KiwiSaaS Story

        "From 2021 to 2024, the KiwiSaaS community brought together nearly 4,000 SaaS professionals from across Aotearoa — sharing knowledge, swapping stories, and helping each other grow. 

        With support from government funding during that time, KiwiSaaS helped the sector collaborate more deeply and amplify New Zealand’s impact on the global stage. 

        Now, we’ve stepped into an exciting new chapter as an industry‑led community within the NZTech Group. This evolution keeps the momentum going — creating a member‑driven network built for long‑term growth, empowering SaaS businesses and championing their success worldwide." - KiwiSaaS

        Monthly Chats

        Back then, I would use "KiwiSaaS Orbit" to get a monthly chat at random with someone in NZ who's also a startup founder. I learned a great deal from those meetings. Everyone was learning by the seat of their pants, trying to solve very hard problems. Then the funding was removed, and KiwiSaaS was dead.

        Resource

        In late 2025, KiwiSaaS was revived and is now run by volunteers. There is a growing list of resources that I'm finding very useful. The talks on Zoom are great.

        Ajabbi is a bootstrap startup, so funds are very tight, and making careful decisions is necessary. I'm glad Ajabbi has joined KiwiSaaS now. Later, Ajabbi can join OMG, etc.

        Volunteering

        It would be good to volunteer to help KiwiSaaS.

        Update 5/12/2025

        Just paid the KiwiSaaS annual membership invoice that came from XeroPayment was made via Stripe using the Link feature. It was very nice to use. Someone in an NZTE course post-advisory session recommended that Ajabbi use Stripe as the payment gateway. Now I can see why.

        Resources

        References

        • Reference

        Repository

        • Home > Ajabbi Research > Library >
        • Home > Handbook > 

        Last Updated

        11/12/2025

        Welcome to KiwiSaaS! Your team’s member benefits start now

        By: 
        KiwiSaaS: 27/11/2025

        We provide the following free of charge to all our SaaS community:

        • Workshops – online and in person, across a range of SaaS areas. Our events page
        • Resource library – with 20+ videos/articles that we’ll build as we go. Our Resource Library 
        • SaaS in My Region –KiwiSaaS supports local organisers to run and grow your local tech and SaaS groups. What’s happening in my area? 
        • News + views relevant to you – News, articles, events and opportunities for people in your role, in your area. Sign up for our newsletter 

        As a member, your whole team now has access to additional premium benefits:

        • Member-only events – Step behind the velvet rope. Get access to exclusive sessions, private roundtables, and invite‑only gatherings — including special events for $5M+ ARR members. > See what’s on 
        • Tune Up consultations– Think of it as a friendly pit stop for your SaaS. Get a free “tune‑up” on any part of your business from experienced Kiwi operators who’ve already run that race. > Request Tune-Up 
        • Intros to connections and mentors– Wish you could pick the brain of someone a few million ARR ahead? We’ll connect you with mentors, peers, and helpers who’ve been there — tailored to your goals and growth stage. > Request an intro 
        • SaaS board exchange – Ever wondered, “Is our board supposed to run like this?” Visit and observe other SaaS boards in action, and learn how the best in the business tackle governance and growth. > Find out more 
        • Discounted tickets to events – Members save on workshops, masterclasses, parties, and our flagship Southern SaaS conference returning late 2026  — where the whole community gets together (and gets inspired). > Southern SaaS 
        • SaaS Helpdesk– Got a SaaS‑shaped problem? Fire away. Our team will connect you with the right advice — fast, practical, and no jargon required. > Ask for help 
        • Member-only resources (coming 2026) – We’ll always share public resources, but from next year you’ll also get access to member‑exclusive toolkits designed to save leaders serious time (and cash) on professional development.
        • Member-only communities - Connect with peers on your wavelength — from early‑stage founders to seasoned growth leaders. Safe, collaborative spaces to swap ideas and scale smarter. Member Communities 
        • Member logo - Please find attached 3 versions of our Member logo for use on your website and marketing collateral. Standard branding rules apply, so please do not alter the logo in any way.

        Plus, you get the good vibes that come from knowing your membership is enabling us to keep most of our offerings free to strengthen our community and grow our sector.

        So where do we start with all this?

        1. Take advantage of your new membership benefits. The list is above, get stuck in. 
        2. Let your team know you are in. Either let them know, encouraging them to sign up to our newsletter, or have us email them for you. If you want to do comms to your team,  we’ve got ready-to-go announcement copy to make it easy > here.  
        3. Let us know your asks and offers. We want to understand what we can do for you, and what you can do for the community. As a membership organisation, KiwiSaaS relies on both your fees (to pay for the organising we do) and a range of volunteers that bring the learning and connection of our community to life. Let us know what you want from us, and what you and your team want to offer.

        AI 101: What is Continual Learning?

        Mike's Notes

        This came through today. I copied part of Alyona Vert's article from TuringPost below as an introduction to these papers

        • A Comprehensive Survey of Continual Learning: Theory, Method and Application, by Liyuan Wang, Xingxing Zhang, Hang Su, and Jun Zhu, submitted to ArXiv on 31/01/2023.
        • Continual Learning and Catastrophic Forgetting by Gido M. van de Ven, Nicholas Soures, Dhireesha Kudithipudi, submitted to arXiv on 8/03/2024

        Resources

        References

        • A Comprehensive Survey of Continual Learning: Theory, Method and Application by Liyuan Wang, Xingxing Zhang, Hang Su, Jun Zhu.
        • Continual Learning and Catastrophic Forgetting by Gido M. van de Ven, Nicholas Soures, Dhireesha Kudithipudi.

        Repository

        • Home > Ajabbi Research > Library >Subscriptions > Turing Post
        • Home > Handbook > 

        Last Updated

        27/11/2025

        AI 101: What is Continual Learning?

        By: Alyona Vert
        Turing Post: 27/11/2025

        Joined Turing Post in April 2024. Studied control systems of aircrafts at BMSTU (Moscow, Russia), where conducted several researchers on helicopter models. Now is more into AI and writing.

        Can models add new knowledge without wiping out what they already know? We look at why continual learning is becoming important right now and explore the new methods emerging for it, including Google’s Nested Learning and Meta’s Sparse Memory Fine-tuning.

        If you think about the term AGI, especially in the context of pre-training, you will realize that the human being is not an AGI, because a human being lacks a huge amount of knowledge. Instead, we rely on continual learning. - Ilya Sutskever

        Do you feel this shift too? The idea of models learning endlessly is showing up everywhere. We see it, we hear it, and it’s all pushing the spotlight toward continual learning.

        Continual learning is the ability to keep learning new things over time without forgetting what you already know. Humans do this naturally (as Ilya Sutskever also noted) and they are very flexible to changing data. But, unfortunately, neural networks are not. When developers change the training data, they often face something that is called catastrophic forgetting: the model starts loosing its previous knowledge, and returns to training model from scratch.

        Finding the very balance between a model’s plasticity and its stability in previously learned knowledge and skills is becoming a serious challenge right now. Continual learning is the path to more “intelligent” systems that will save time, resources, and money spent on training, it helps mitigate biases and errors, and, in the end, things can just go easier and more naturally with model deployment.

        Today we’ll look at the basics of continual learning and two approaches that are worth your attention: very recent Google’s Nested Learning and Meta FAIR’s Sparse Memory Finetuning. There is a lot to explore →

        Follow us on YouTube

        In today’s episode, we will cover:

        • Continual Learning: the essential basics
        • Setups and scenarios for Continual Learning training
        • How to help models learn continually? General methods
        • What is Nested Learning?
          • How does Nested Learning work?
          • HOPE: Google’s architecture for continual learning
          • Not without limitations
        • Cautious continual learning with memory layers
          • Sparse Memory Finetuning
          • Limitations
        • Conclusion / Why continual learning is important now?
        • Sources and further reading

        Continual Learning: The essential basics

        Continual learning means learning step-by-step from data that changes over time. So it is related to two main things:

        • Non-stationary data, which means the data distribution does not stay the same and keeps shifting.
        • Incremental learning – the model should add new knowledge without wiping out what it learned before.

        The new pieces of information can be new skills, new examples, new environments, or new contexts. As the data comes in gradually, continual learning is also known as lifelong learning. The process of continual learning happens when the model is already deployed.

        Everything would be great if models didn’t face one major challenge – catastrophic forgetting. This problem generally looks like this: a neural network is trained on Task 2 after Task 1, and its weights are updated for Task 2. This often pushes them away from the optimum for Task 1, and the model suddenly performs very poorly on that task.

        The problem here is not the model’s capacity – this usually happens because of the sequential training procedure. Even in 1989-1990, Michael McCloskey and Neal J. Cohen and R. Ratcliff identified this problem and showed that simple networks lose previous knowledge extremely quickly when trained sequentially. They also highlighted that this forgetting is much worse than in humans.

        But if you train on Tasks 1 and 2 interleaved, forgetting does not happen.

        Image Credit: Illustration of catastrophic forgetting, “Continual Learning and Catastrophic Forgetting” paper

        Preventing forgetting is only one part of the solution. Effective continual learning also requires:

        • Fast adaptation
        • Ability to leverage task similarities
        • Task-agnostic behavior
        • Robustness to noise
        • High efficiency in memory and compute
        • Avoiding storing all past data and retraining on all previous data

        If tasks are related, the model should get better at one after learning another, which marks positive knowledge transfer:

        • Forward transfer → Task 1 helps Task 2 later.
        • Backward transfer → Task 2 helps improve Task 1. This is a more difficult variant for neural networks.

        So, a good continual learning system needs the right balance: it should stay stable (not forget old things) while still being plastic enough to learn new ones. It also needs to handle differences within each task and across different tasks. How is it released on practice?


        Image Credit: “A Comprehensive Survey of Continual Learning: Theory, Method and Application” paper

        Setups and scenarios for Continual Learning training

        Continual learning is mainly about moving from one task to the next while keeping performance stable or improving it during ongoing learning. That’s why two fundamental setups are used for it:

        1. Task-based continual learning: Data is organized into clear, separate tasks which are shown one after another, with explicit task boundaries. It is the most common setup, because it is convenient and controlled – you know exactly when tasks switch. But it doesn’t represent gradual changes found in the real world, and models may rely too heavily on boundaries for memory updates.
        2. Task-free continual learning: This one is more realistic, because it better reflects real-world data where distributions shift continuously. There is still an underlying set of tasks, but task boundaries are not given and transitions are smooth.


        Image Credit: “Continual Learning and Catastrophic Forgetting” paper

        Continual learning researchers often uses three main scenarios to describe what the model is expected to know at test time and whether it gets task identity information. Importantly, these scenarios are defined by how the changing data relates to the function the network must learn:


        Upgrade to read the rest on Turing Post

        A formalization of foundations of geometry in Coq

        Mike's Notes

        Very useful. Proof assistants are wonderful tools. The original article has many links to source material.

        Resources

        References

        • Reference

        Repository

        • Home > Ajabbi Research > Library >
        • Home > Handbook > 

        Last Updated

        27/11/2025

        A formalization of the foundations of geometry in Coq

        By: Julien Narboux
        GeoCoq: Copied 27/11/2025

        About

        This page describe a formalization of geometry using the Coq proof assistant. It contains both proofs about the foundations of geometry and high-level proofs in the same style as in high-school.

        Content

        You can browse the Coq files from the index of everything, or you can see some specific parts.

        The axiom systems

        There are several ways to define the foundations of geomety:

        • a synthetic approach
          • Hilbert's axioms: points, lines, planes + geometric axioms
          • Tarski's axioms: points + geometric axioms
          • Euclid's axioms
        • an analytic approach (starting from a given field 𝔽 (usually ℝ) and defining the plane as 𝔽n),
        • mixed analytic/synthetic approaches assuming both a field and some geometric axioms:
          • area method: start field a field for measure signed distance and areas + geometric axioms
          • Birkhoff's style axiom system: start with a field for measuring distance and angles + geometric axioms
        • approaches based on group of transformations (Erlangen progam)...

        We use here a synthetic approach but following Descartes and Tarski we prove that we can define coordinates.

        Our main axiom system is the one of Tarski, but we define also Hilbert's axiom system and a version of Euclid's axioms sufficient to prove the propositions in Book 1 of the Elements. We prove that Tarski's axioms (except continuity) are equivalent to Hilbert's axioms (except continuity).

        • Tarski axioms in Coq
        • Hilbert axioms in Coq
        • Euclid axioms in Coq

        The formalization based on Tarski's axioms

        Alfred Tarski 1968TarskiIn the early 60s, Wanda Szmielew and Alfred Tarski started the project of a treaty about the foundations of geometry. A systematic development of euclidean geometry based on Tarski's axioms was supposed to constitute the first part, but the early death of Wanda Szmielew  put an end to this project. Finally, Wolfram Schwabhäuser continued the project of Wanda Szmielew and Alfred Tarski. He published the treaty in 1983 in German : Metamathematische Methoden in der Geometrie.

        The main part of this development consists in the formalization of the first part of this book.SST Book

        • Ch. 02 congruence properties
        • Ch. 03 between properties
        • Ch. 04 congruence between properties 
        • Ch. 04 collinearity 
        • Ch. 05 between transitivity le 
        • Ch. 06 out lines 
        • Ch. 07 midpoint 
        • Ch. 08 orthogonality 
        • Ch. 09 planes
        • Ch. 10 line reflexivity 2D
        • Ch. 10 line reflexivity
        • Ch. 11 angles
        • Ch. 12 parallelism
        • Ch. 12 parallelism using intersection decidability
        • Ch. 13 Synthetic proofs of the theorems of Pappus and Desargues.Pappus
          • Ch. 13.1
          • Ch. 13.2 length Length defined as an equivalence class using the Congruence predicate.
          • Ch. 13.3 angles Angle measure defined as an equivalence class using the angle congruence predicate.
          • Ch. 13.4 cos
          • Ch. 13.5 Pappus-Pascal
          • Ch. 13.6 Desargues Hessenberg's proof that Pappus implies Desargues.
        • Ch. 14 Arithmetization of geometry
        • Descartes Geometrie
          • Ch. 14.1 Sum Geometric definition of the sum.
          • Ch. 14.2 Product Geometric definition of the product.
          • Ch. 14.3 Ordered. field All proofs necessary to obtain an ordered field.
        • Ch. 15 Lengths Definition of the length of a segment using coordinates, definition of length comparison using coordinates, proof that these definitions corresponds to their geometric counterparts,
        • Pythagoras theorem,
        • Thales theorem.
        • Ch. 16 Coordinates Proof of the formulas to characterize the congruence, betweeness, collinearity, and midpoint predicates using coordinates.
        • Ch. 16 extension Instanciation of ordered field structures and connection to the nsatz tactic to obtain proof automatically using Gröbner basis.

        Formalization of Euclid's Elements

        Euclid's ElementsRegarding the formalization of Euclid's Elements, GeoCoq contains two different approaches:

        • The first approach consists in formalizing Euclid's statement without trying to formalize the original proofs. Then we can try to minimize the assumptions. For example, we have formalized Gupta's proof that midpoint can be constructed without circle/circle continuity axioms, whereas the original proof by Euclid needs this assumption. We gather Euclid's statements formalized using this approach here. Export to html with GeoGebra figures is here.
        • The second approach consists in trying to formalize the original proofs. This is not completly possible because Euclid's proofs contain some gaps, but it can be done to some extent by introducing the missing axioms and reordering some proofs. The formalization of first book of the Elements has been completed by Michael Beeson and then exported to Coq. The details are given in this paper. These proofs have also been exported to Hol-Light, see the webpage of this project. The Coq formalization consists of:
          • Our formalization of Euclidean axioms
          • Some adhoc tactics
          • Book 1
          • Going from Tarski's axiom to Euclid's axioms
          • Going from Euclid's axioms to Tarski's axioms

        Connection with Hilbert's axioms

        HilbertGrundlagen der GeometrieMost of the axioms of Hilbert are implicitely proved in the book, but we explicit all the proofs here. We define lines by couple of points... This work is described in this paper. We also proved that Tarski's axioms follow from Hilbert's axioms.

        • Tarski to Hilbert A proof that Hilbert's axioms (without continuity) follows from Tarski's axioms.
        • Hilbert to Tarski A proof that Tarski's axioms (without continuity) follows from Hilbert's axioms.

        Variants of Tarski's axiom system

        • Tarski to Makarios The formalization of a short paper by Makarios which shows that by slightly changing an axiom we can delete another axiom.
        • Tarski to Beeson The proof that the constructive axiom system proposed by Beeson is classicaly equivalent to Tarski's axiom system.

        Parallel postulates

        We prove the equivalence between several versions of the parallel postulate including the well known Playfair's postulate, and Euclid's 5th postulate. The details are in this paper.

        • Definition of several versions of the parallel postulate.
        • The equivalences theorems summarizing the results.

        High level theorems

        To give a demonstration of the library and of the tactics, we prove some classical results of high-school geometry.

        • Midpoints theorems
        • Midpoint Thales theorem
        • Varignon's theorem
        • Quadrilaterals and Quadrilaterals with inter dec Definition of some quadrilaterals and their properties.
        • Orthocenter theorem
        • Circumcenter theorem
        • Gravity center theorem
        • Incenter theorem
        • Euler line
        • Circumcenter theorem
        • Euler line

        Contributors

        • Michael Beeson
        • Pierre Boutry
        • Gabriel Braun
        • Roland Coghetto
        • Charly Gries
        • Julien Narboux
        • Dan Song

        Forum

        If you seek help about GeoCoq please using the following forum:

        GeoCoq Group

        Licence

        The code is released under the terms of the LGPL version 3.0.

        How to install

        We are working on a proper documentation, please contact us if you need help.

        Manual installation

        To use this library, first you need to install Coq.

        We tested GeoCoq 2.3.0 using Coq versions 8.5pl3, 8.6.1 and 8.7beta. The previous versions are not supported. Then download and unpack the files, it will create a GeoCoq directory. In this directory, type ./configure and make to compile the files, it will create some .vo files. It takes a while to compile (> 30 minutes).

        Using opam pacakage manager

        You can install both Coq and GeoCoq using opam. See the instructions here.

        opam repo add coq-released https://coq.inria.fr/opam/released
        opam install -j4 -v coq-geocoq

        Related papers

        (To come)

        Related work

        Larry Wos and Michael Beeson have studied Tarskian Geometry using Otter: An amazing approach to plane geometry and Finding Proofs in Tarskian Geometry.

        The Workflow Pattern

        Mike's Notes

        More ideas here for the Workflow Engine from Yves.

        Resources

        References

        • Reference

        Repository

        • Home > Ajabbi Research > Library >
        • Home > Handbook > 

        Last Updated

        26/11/2025

        The Workflow Pattern

        By: Yves Reynhout
        Bit Tackler: 27/01/2023

        Mike is the inventor and architect of Pipi and the founder of Ajabbi.

        Over the past months I've finally started experimenting with event sourced workflows, a topic I had been contemplating the last couple of years. I've taken inspiration from other products and projects, such as AWS Step Functions, GCP Workflows, Temporal, Camunda, NServiceBus and MassTransit to name a few. I do want to point out that, if you're already heavily invested into any of these, I'm not trying to deter you from using them. After all, a simple pattern can't cover the breadth of a product. If you're applying event sourcing, then the following pattern may prove to be a useful stepping stone.

        Why did I decide to climb this particular hill? Well, on the one hand, I'm not particularly overwhelmed by the workflow authoring experience some products offer. Granted, this may be a point of contention. On the other hand, there are not a lot of examples out there on how to model an event sourced workflow. But more importantly, it was an itch and scratching was long overdue.

        What's a workflow anyway? For the purpose of this blog post, I'm going to position it as the automation of a process (or part of), which we can not accomplish in a single transaction for one reason or another. Those reasons may be either technical or functional, or both. In this context, workflows are more akin to process managers than sagas, meaning a central unit that orchestrates. In my experience most workflows can be modelled as state machines or state charts. The ability to visualize those state machines in turn helps us see the bigger picture and spot deficiencies in our design more easily.

        Where do workflows sit? Sometimes at the edges of, other times inside of a service or bounded context or module, however you partitioned your system. This is also the reason why their codified form may only be a small part of the overall process we're trying to automate. Central unit does not automatically mean centralize the whole process. If we were to do that, we would bring ourselves in the awkward position of not knowing where to place it. Instead multiple workflows, each one in its proper place, can, together, automate the overall process, to whatever degree we deem useful. At the edges they can observe both the outside and inside and mediate between them, sometimes acting as a slayer of corruption, other times as a translator from Babylon.

        What's the purpose of a workflow? To make decisions that help move the overall process along. Each decision causes a state transition, meaning it goes from its current state to the next state, even if that next state is the same state as the current state. This is acceptable, since we only make those states explicit that have a particular relevance to moving the process along. Well-designed workflows have a beginning, a middle and an ending. Unless they are really simple, it's recommended to zoom in on each of those stages in a workflow's lifecycle. That implies something causes them to begin, to continue to be, and to end. In state machine speak, we call the things that cause transitions triggers. Not surprisingly, messages make for good trigger candidates.

        Most workflows I encounter in existing implementations are more like riddles. Hidden as columns or properties ending with a suffix called Status or State, often having Active, Inactive, Pending, InProgress as values. Mingled with other boolean columns and properties, they indicate we're in a certain state of the machine, thus somewhere along in the process. Does this sort of implementation sound familiar? I'm sure it does for some of you. It baffles me how well versed domain experts and their proxies have become in this sort of column speak. The meaning of the words used as values has to be explained over and over again because they are in effect weasel words. It's as if they've forgotten how to articulate a process without leaning on the current solution as a crutch. All the more reason to make the implicit explicit, if you ask me.

        When it comes to implementing workflows there are quite a few problems that need to be tackled:

        • How is a workflow initiated and how do you know which workflow to initiate?
        • How will messages be delivered to a workflow?
        • How are messages correlated to a workflow?
        • What does a workflow need to remember, how and where do we store that memory, how do we correlate that memory back to a workflow?
        • How and when are the decisions taken by a workflow executed?
        • How can we observe workflow execution behavior?

        This list is not exhaustive by any means but already covers some interesting problems. Most of these can be considered generic, that is, the solution is the same regardless of which workflow we're trying to implement. The components that take care of these problems are often highly reusable and not surprisingly, products, libraries and frameworks are born to solve them. With that in mind, the part that's left is the authoring experience for developers.

        Explaining workflows in the abstract is one thing, yet binding it to concrete examples is much more useful. So before we continue, I have to tell you a true story. Well, more like a confession 😉

        I recently received a letter from the police with an amicable settlement for a traffic violation I committed. It's a rare phenomenon, me breaking a speed limit, but it did happen on this particular occasion. The car the infraction was committed with is registered to my company, which explains why the letter was addressed to my company instead of my person. In Belgium you've got 15 days to identify who drove the car at the time of the infraction - they can't make any assumptions - or, if you don't know who drove it, who is responsible for the car. Failing to do so causes you to incur a higher fine or being prosecuted. The reason this is required is because the infraction has to be added to someone's criminal record. Once the driver is identified, they in turn will be invited to pay the fine. You can still choose to pay as a company or have the driver pay. You can choose the order in which you perform payment and driver identification. You can ask for a payment plan but you have to realize it's limited to 6 months. If you don't pay in time, you'll get payment reminders but the fine itself will increase with each reminder. You can contest the fine and have this dispute accepted or rejected. If the dispute is rejected, the procedure continues as though it wasn't contested - you just have to pay the fine. Not paying causes all kinds of escalations that would have me stray too far off topic.

        There's plenty of possible workflows in the above narrative, if you ask me. I've taken some liberty in how I've modelled them, so bear with me.

        At the edges of the subsystem that deals with traffic fines lives a workflow that's observing the police's reporting subsystem.


        Its job is to make sure that police reports that report a speeding violation cause a traffic fine to be issued to the subject to whom the vehicle is registered.


        Not all police reports are about speeding violations. For simplicity sake, I've kept it to one offense per police report. If there's no speeding violation, we simply complete the workflow immediately, signaling that it is done. If it does have a speeding violation as an offense, we need to generate a number that uniquely identifies the traffic fine we're about to issue in the rest of the system. Because the people to whom the car is registered may not have a digital identity - that's a thing in Belgium - or simply do not want to use their digital identity, we also need to generate a manual identification code, which, together with the police report and traffic fine number, can be used to access the website where you can view, pay and contest your traffic fine. Once all that's done, we can issue the traffic fine and complete the workflow. Nothing too complicated, in my opinion.

        This all largely happens within the Traffic Fine System. There's a perpetual motion of messages flowing in and out of the workflow as depicted below. You can enlarge the image by clicking it and follow the sequence numbers.

        Let's see what that could look like in code. I've picked F# for this purpose, leaning on its terseness, but honestly, this can be expressed in just about any programming language in a very similar way.

        module IssueTrafficFineForSpeedingViolationWorkflow =
            let decide message state =
                match (message, state) with
                | PoliceReportPublished m, Initial ->
                    match m.Offense with
                    | SpeedingViolation _ -> 
                        [ Send(
                            GenerateTrafficFineSystemNumber { 
                              PoliceReportId = m.PoliceReportId }) ]
                    | _ -> [ Complete ]
                | TrafficFineSystemNumberGenerated m,
                    AwaitingSystemNumber s ->
                    [ Send(
                        GenerateTrafficFineManualIdentificationCode
                          { PoliceReportId = s.PoliceReportId
                            SystemNumber = m.Number }
                      ) ]
                | TrafficFineManualIdentificationCodeGenerated m,
                    AwaitingManualIdentificationCode s ->
                    [ Send(
                        IssueTrafficFine
                          { PoliceReportId = s.PoliceReportId
                            SystemNumber = s.SystemNumber
                            ManualIdentificationCode = m.Code }
                      )
                      Complete ]
                | _, _ -> 
                  failwithf "%A not supported by %A" message state

        Even if you're not familiar with F# and its pattern matching, the translation from the state diagram into its code counterpart should be fairly intuitive.

        This workflow is initiated by the PoliceReportPublished message. Nothing happened yet so we must be in the Initial state of the workflow. If the offense on the police report is a SpeedingViolation, we go ahead and decide to Send a GenerateTrafficFineSystemNumber message. If it's a ParkingViolation, we decide to immediately Complete the workflow.

        We then wait for the system number to be generated. Once that happens, the workflow is continued by the TrafficFineSystemNumberGenerated message and it appears we've somehow moved onto the AwaitingSystemNumber state. The only decision to take, now that we know the system number, is to Send a GenerateTrafficFineManualIdentificationCode message.

        No surprise here, we wait for that manual identification code to be generated. Again, once that happens, the workflow is continued by the TrafficFineManualIdentificationCodeGenerated message and we've moved onto the AwaitingManualIdentificationCode state.

        Our final decision is to Send the IssueTrafficFine message and to Complete the workflow. If we encounter a transition our state machine does not understand, we can either ignore it by returning no decisions or fail hard and complain about it, which is what we've done here.

        Why don't we pause here for a minute. There's something important to point out. That is, our workflow takes decisions. But it doesn't actually perform the decisions taken. Making sure those decisions get executed is a delegated responsibility. Its decision logic is side effect free and can be implemented as a pure function. This property is not to be underestimated. Testing the workflow's decision logic becomes fairly straightforward. All you need is a message and some state to check whether the actual decisions taken match the ones you expected to be taken. If we generalize the signature of our workflow's decide function we get something along the lines of this:

        type Workflow<'TInput,'TState,'TOutput> = {
            Decide: 'TInput -> 'TState -> WorkflowCommand<'TOutput> list
        }

        It takes an input message and the current state it is in, and produces a list of decisions. For now, with what we've seen so far, that WorkflowCommand - what I've called decision up until now - is fairly simple. We either decide to send a message or to complete the workflow.

        type WorkflowCommand<'TOutput> =
            | Send of message: 'TOutput
            | Complete

        Not all workflows are created equal. Some may also publish an event, schedule a timeout message to themselves, or reply to whomever sent the input message to the workflow, or any mixture of those. The point is to only include those decisions any of your workflows are going to take. The set of decisions, the sending, publishing, replying, scheduling, completing, etc ... it is finite and that's a good thing. It means we can start thinking about generalizing it.

        type WorkflowCommand<'TOutput> =
            | Reply of 'TOutput
            | Send of 'TOutput
            | Publish of 'TOutput
            | Schedule of after: TimeSpan * message: 'TOutput
            | Complete

        When the workflow is about to begin, meaning it's about to receive its first message and take its first decisions, there's no previous state we know of. So we have to tell what that initial state is, that is the state it is in when we start out. The fact I called it Initial in the IssueTrafficFineForSpeedingViolationWorkflow above is purely happenstance. I could have called it AwaitingPoliceReportWithPossibleSpeedingViolation instead. For simplicity sake, I've encoded it into the workflow itself as InitialState, because, well, it has to be encoded somewhere for workflow surrounding code to be able to find and use it. This way, when calling the decide function for the first time, we know what initial state to pass in.

        type Workflow<'TInput,'TState,'TOutput> = {
            InitialState: 'TState
            Decide: 
                'TInput 
                -> 'TState 
                -> WorkflowCommand<'TOutput> list
        }

        Often there's a desire to encapsulate information next to the actual message we receive as input. This often goes by a name ending with the word Context in a lot of other products. Here I called it WorkflowTrigger. It can be useful at times if the workflow surrounding code treats the head and body of a message as distinct parts, but it's not strictly necessary. The workflow trigger could have properties which we tend to classify as metadata or headers, e.g. source, message name, message version, causation and correlation identifiers, ... next to the body. The main motivation to use this is when the workflow code itself uses it as part of its decision making process. If not, then it's just noise.

        type Workflow<'TInput,'TState,'TOutput> = {
            InitialState: 'TState
            Decide: 
                WorkflowTrigger<'TInput> 
                -> 'TState 
                -> WorkflowCommand<'TOutput> list
        }

        Another missing piece of the puzzle that probably had you 🥁 ... puzzled is how we actually moved thru the state machine. There's an additional function that takes care of that.

        module IssueTrafficFineForSpeedingViolationWorkflow =
            let evolve state message =
                match (state, message) with
                | Initial, InitiatedBy(PoliceReportPublished m) ->
                    match m.Offense with
                    | SpeedingViolation v -> 
                        AwaitingSystemNumber { 
                          PoliceReportId = m.PoliceReportId }
                    | ParkingViolation v -> Final
                | AwaitingSystemNumber s,
                    Received(TrafficFineSystemNumberGenerated m) ->
                    AwaitingManualIdentificationCode {
                      PoliceReportId = s.PoliceReportId
                      SystemNumber = m.Number }
                | AwaitingManualIdentificationCode _,
                    Received(TrafficFineManualIdentificationCodeGenerated _) ->
                    Final
                | _, _ -> 
                  failwithf "%A not supported by %A" message state

        Again, it's a fairly straightforward translation from the state diagram. We start out in the Initial state, the solid sphere on the diagram. What name you want to give this state is totally up to you - whatever makes most sense. Receiving or rather, being initiated by a PoliceReportPublished message, in this state causes it to transition either to the Final state, when no speeding violation, or the AwaitingSystemNumber, when there's a speeding violation. Not only do we transition to the next state, we also copy any necessary data we need to keep track of onto the next state. This data can come from the message, the previous state or simply by virtue of transitioning. Receiving TrafficFineSystemNumberGenerated moves us from AwaitingSystemNumber to AwaitingManualIdentificationCode and, finally, receiving TrafficFineManualIdentificationCodeGenerated moves us from AwaitingManualIdentificationCode to the Final state. If we encounter a transition our machine does not understand, we can either ignore it by returning the same state or fail hard and complain about it, which is what we've done here. Our workflow signature can now cater for the evolve function too.

        type Workflow<'TInput,'TState,'TOutput> = {
            InitialState: 'TState
            Evolve: 'TState -> WorkflowEvent<'TInput, 'TOutput> -> 'TState
            Decide: 'TInput -> 'TState -> WorkflowCommand<'TOutput> list
        }

        So the evolve function takes a previous state and an event, and produces the next state, which may just well be the same state. But what's that WorkflowEvent<'TInput, 'TOutput> you have there? Well, evolving the state of a workflow should be able to observe both the past received input and past taken decisions. WorkflowEvent<'TInput, 'TOutput> is what embodies that. In the above evolve the workflow state was only moved based on workflow input, but that's just a coincidence.

        type WorkflowEvent<'TInput, 'TOutput> =
            | Began
            | InitiatedBy of 'TInput
            | Received of 'TInput
            | Replied of 'TOutput
            | Sent of 'TOutput
            | Published of 'TOutput
            | Scheduled of after: TimeSpan * message: 'TOutput
            | Completed

        Using this construct, we can start telling the story of a typical workflow execution. Do note that I've put the messages on a diet for legibility reasons.

        [
            Began
            InitiatedBy(PoliceReportPublished { 
                PoliceReportId = "XG.96.L1.5000267/2023"
                Offense = SpeedingViolation { MaximumSpeed = "50km/h" } })
            Sent(GenerateTrafficFineSystemNumber { 
                PoliceReportId = "XG.96.L1.5000267/2023"})
            Received(TrafficFineSystemNumberGenerated { 
                PoliceReportId = "XG.96.L1.5000267/2023"
                Number = "PPXRG/23TV8457" })
            Sent(GenerateTrafficFineManualIdentificationCode { 
                PoliceReportId = "XG.96.L1.5000267/2023"
                SystemNumber = "PPXRG/23TV8457" })
            Received(TrafficFineManualIdentificationCodeGenerated { 
                PoliceReportId = "XG.96.L1.5000267/2023"
                Number = "PPXRG/23TV8457"
                Code = "XMfhyM" })
            Sent(IssueTrafficFine { 
                PoliceReportId = "XG.96.L1.5000267/2023"
                SystemNumber = "PPXRG/23TV8457"
                ManualIdentificationCode = "XMfhyM" })
            Completed
        ]

        By now it should start to dawn on you how event sourcing fits into all of this. Using WorkflowEvent<'TInput, 'TOutput> we can build up a stream of events around the workflow: when it began, what message initiated it, which decisions were taken, how more messages we've received continued it and more decisions were taken, until finally it completed. Past tense is great for evolving and describing history, but I prefer imperative for decision making, which is why WorkflowCommand<'TOutput> is there. While it's a great authoring tool, it's a transient concept nonetheless. It's trivial to translate between the two since it's mainly a matter of knowing whether we're beginning the workflow and turning the imperative (a workflow command) into past tense (a workflow event). By piping all messages and their accompanying decisions, i.e. our workflow commands, thru this translation, we get the entire history of a workflow as a sequence of events.

        let translate begins message commands =
            [
                if begins then
                    yield Began
                    yield InitiatedBy message
                else
                    yield Received message
                for command in commands do
                    yield 
                        match command with
                        | Reply m -> Replied m
                        | Send m -> Sent m
                        | Publish m -> Published m
                        | Schedule (t, m) -> Scheduled (t, m)
                        | Complete m -> Completed m
            ]

        Note that, if you are using EventStoreDB, you could use the expected stream revision or the absence of a stream, denoted by StreamRevision.None, as the value of begins. You usually obtain this value when you try to read the stream and use the stored events to evolve the workflow.

        Above the choice was made not to bother the workflow author with having to explicitly indicate whether or not the workflow began and which message caused it to be initiated. This is a choice and trade off. Making it part of the decision making simply moves it into the authoring experience. That would work too, and just like for Complete, you must not forget to Begin as part of receiving the initial message. Stitching a Received message in the history becomes a tad trickier and human error starts lurking. The minor difference between Received and InitiatedBy is again a design choice. I've gone back and forth on these sort of things. It's good to play with the subtle differences in a design to see where they take you.

        Now, with that in place and the rather mundane task of encoding input and output messages as json serialized events, it's not that hard to get to a working implementation in EventStoreDB, as shown below.



        What stream name format to pick for a workflow, how we encode the workflow events, it's all a matter of choice. There is no real difference with other event sourced entities in this regard. Encoding the workflow event in the event type caters for a nice form of readability, in my opinion. We could have made the intent, that is to send, to publish, to schedule, etc. part of the meta data of an event instead. By all means, bring your own conventions.

        One thing to watch out for is that workflow streams may have copies of messages that originate from other streams. Without proper precautions in place your projections and other consumers may start observing the same messages twice. You could teach them to ignore workflow streams, you could make sure the event type does not match, etc ... fair warning, that's all.

        At this point, there may be a few concerns.

        We're using past tense to indicate that a message has been sent, published, scheduled, etc. but in reality, that sending, publishing, scheduling, etc. has not taken place yet. When I read a decide function, I can reason about the decisions it's taking using the imperative. Yet as soon as I exit that decide function, it's a done deal, as far as authoring goes at least. I'm now leaning on other parts of the system to actually do the persisting, sending, publishing, scheduling, replying, etc ... and they should not fail for reasons other than technical ones. If we take the perspective of the workflow itself, past tense makes sense. As far as it is concerned, those decisions have happened. If the decisions don't get persisted atomically, we'll retry at a later time or that input message may well end up getting dead-lettered in some way. Now, if the decisions decided do not get carried out, are never carried out, or we suspect the potential for that to happen is there, we should lean on scheduling timeouts for a workflow or make reporting failure back to a workflow explicit. Additionally, we can observe and monitor workflow streams for the one's that Began but never Completed within a reasonable amount of time. This amount of time may be very different from one workflow to another, from one domain to another. Using telemetry may be another angle by which to observe the behavior of our workflows.

        Choosing to persist a workflow's events rather than its commands is a choice. We could ditch the entire concept of a workflow event and simply persist the workflow commands. We could ditch the entire concept of a workflow command and go straight for workflow events while authoring. Having these sort of choices to make is great, just make sure they don't paralyze you.

        Why do we have a difference between the input and output messages? After all, there may be overlap between them. For example, scheduling an output message to oneself after a time will cause that message to appear as input to the same workflow. We could entertain the idea of unifying the input and output messages into one set of messages that the workflow either receives or emits. It simplifies encoding somewhat, but it obviously makes it less clear which messages serve as input or output.

        The words command and event may be used in a way that interferes with what they mean in other areas where you apply event sourcing. Examples? A workflow command to publish an event. A workflow event that indicates a command or query was sent. It's best to view things from the workflow's perspective and to not look too closely at the tense of the message a workflow command carries. Doing anything else is going to hurt your brain.

        Consequently, the type of message that triggers a workflow is deliberately unconstrained and neither is the type of message used as payload of a decision. Whether a workflow gets triggered by an event, a command, a query, a document or a query response does not materially affect the authoring or its design. There really is no need for any sort of artificial translation or shoehorning. For decisions, it makes sense for the payload to match the semantics of the workflow command, be it sending, publishing, replying, scheduling, ... if only for legibility reasons. There's something quite liberating about letting go.

        There's no addressing. We're not specifying where to send or publish to, in the workflow's decide function itself. This is intentional. The name of the message determines its destination. If that's not sufficient, I'd entertain the idea of adding logical endpoints as a destination to Send or Publish to, but never physical endpoints. It'd be fairly insidious to make assumptions about the stability of physical endpoints over the course of time, since the whole point is to keep the history of a workflow persisted as a sequence of events. The map between the logical and physical endpoints could live in code or configuration.

        And ... Action!

        With our workflow events now persisted, our stream itself has developed an interesting property. Not only can we use it to rebuild our workflow's state, by replaying (also known as folding) all workflow events in it using our evolve function, but it effectively acts as an outbox for all the workflow commands that have been written as workflow events. We can use persistent or catch-up subscriptions on top of those streams to have a workflow event processor carry out the workflow commands. Given that the workflow commands are generic, there is a high chance their execution can be generalized. When starting out with this pattern, however, it can be useful to postpone this generalization and get to grips with what carrying out the workflow commands means.

        As an example, assuming we've set up a topic per message type in Google's Cloud Platform, our processor could publish each workflow send command message to its corresponding topic, as shown below. The repetition is fairly easy to spot.

        module IssueTrafficFineForSpeedingViolationWorkflowProcessor =
            let private options =
                JsonFSharpOptions
                    .Default()
                    .WithUnionExternalTag()
                    .WithUnionUnwrapRecordCases()
                    .ToJsonSerializerOptions()
            let private envelop workflow_id message_id m =
                let json = JsonSerializer.Serialize(m, options)
                let envelope =
                    PubsubMessage(Data = ByteString.CopyFromUtf8(json))
                envelope.Attributes.Add("workflow_id", workflow_id)
                envelope.Attributes.Add("message_id", message_id)
                envelope
            let handle clients workflow_id message_id command =
                task {
                    match command with
                    | Send(GenerateTrafficFineSystemNumber m) -> 
                        let envelope = envelop workflow_id message_id m
                        do! 
                            clients
                                .SystemNumberTopic
                                .PublishAsync(envelope) 
                            :> Task
                    | Send(GenerateTrafficFineManualIdentificationCode m) -> 
                        let envelope = envelop workflow_id message_id m
                        do! 
                            clients
                                .ManualIdentificationCodeTopic
                                .PublishAsync(envelope) 
                            :> Task
                    | Send(IssueTrafficFine m) -> 
                        let envelope = envelop workflow_id message_id m
                        do! 
                            clients
                                .IssueTrafficFineTopic
                                .PublishAsync(envelope) 
                            :> Task
                    | _ -> 
                        failwith 
                            "%A command has not been implemented." 
                            command
                }

        The working assumption is that some subscription mechanism is going to deliver the relevant workflow commands to the handle function above. This would involve translating some of those workflow events back into the imperative - another design choice, really depends on what you like to read and write. The handle function takes the topics to publish to, called clients here, as a dependency, along with the workflow's identity, the workflow command's message identity and the actual workflow command, and routes each message to the corresponding topic. Note that there's no reason to put all of a workflow's command processing in one function nor to publish messages on a topic. These are all implementation choices and for illustration purposes only.

        It's important that, whatever you do, the workflow identity flows along. Performing the GenerateTrafficFineSystemNumber command is what causes the TrafficFineSystemNumberGenerated event, same for GenerateTrafficFineManualIdentificationCode causing TrafficFineManualIdentificationCodeGenerated. The event needs to make its way back to our workflow. But which workflow? That's the purpose of the workflow identity, it acts as a correlation identifier.

        It goes without saying that keeping track of the position you've reached in a catch-up subscription in a durable fashion matters. Not the kind of data you can afford to lose, if you catch my drift. There's always some of that at play, meaning that in the absence of a distributed transaction across the resource that stores the position and the resources that you interact with to perform the workflow command, the workflow command's message identity is your friend. It can help deal with idempotency. For persistent subscriptions things are subtly different, in that which workflow commands we've already observed is managed for us, but the chance of observing the same workflow command more than once is still present.

        If you're into Event Modeling, you could use the Automation Pattern instead to carry out the workflow commands, using a todo list in the middle. This in turn would allow you to do things like batch executing commands across workflows, e.g. if what you're calling is costly.

        I don't want to dwell too much on the implementation of an outbox nor on how messages make their way (back) to a workflow. These are solved problems and the implementation is highly contextual.

        Look! No events!

        There's a variation on this pattern that foregoes the use of event sourcing or an event store for that matter(*). If we draw inspiration from The Elm Architecture, it's not that far of a stretch to design the workflow as:

        type Workflow<'TInput,'TState,'TOutput> = {
            InitialState: 'TState
            Decide: 
                'TInput 
                -> 'TState 
                -> ('TState * WorkflowCommand<'TOutput> list)
        }

        Here, the decide function takes an input message and a state, and produces a tuple of the new state and the decisions taken. No events in sight. Applied to our workflow it may look like this:

        module IssueTrafficFineForSpeedingViolationWorkflow =
            let decide message state =
                match (message, state) with
                | PoliceReportPublished m, Initial ->
                    match m.Offense with
                    | SpeedingViolation _ ->
                        (AwaitingSystemNumber { 
                            PoliceReportId = m.PoliceReportId },
                         [ Send(
                             GenerateTrafficFineSystemNumber { 
                               PoliceReportId = m.PoliceReportId }) ])
                    | _ -> (Final, [ Complete ])
                | TrafficFineSystemNumberGenerated m,
                    AwaitingSystemNumber s ->
                    (AwaitingManualIdentificationCode
                        { PoliceReportId = s.PoliceReportId
                          SystemNumber = m.Number },
                     [ Send(
                         GenerateTrafficFineManualIdentificationCode
                           { PoliceReportId = s.PoliceReportId
                             SystemNumber = m.Number }
                     ) ])
                | TrafficFineManualIdentificationCodeGenerated m,
                    AwaitingManualIdentificationCode s ->
                    (Final,
                     [ Send(
                         IssueTrafficFine
                           { PoliceReportId = s.PoliceReportId
                             SystemNumber = s.SystemNumber
                             ManualIdentificationCode = m.Code }
                       )
                       Complete ])
                | _, _ -> 
                  failwithf "%A not supported by %A" message state

        Effectively, we've merged the behavior of the decide and evolve function. Imagine we're using a relational database to persist the workflow state and commands. We can do that in one transaction and use the outbox pattern for the workflow commands. There's not a lot of difference to it, is there?

        (*) What if I told you, you could use the event sourced version above instead and still be able to persist the workflow state and commands. All you're sacrificing in the end is remembering the past events. You would not know how you got to the workflow's current state, you'd only know what the workflow's current state is. When decisions come out of the workflow's decide function, you can translate them into events, feed them to its evolve function, and out would come the next state to be persisted alongside the commands. In effect, from the workflow authoring perspective, it does not materially matter how or what you persist. Only whether you use the decide and evolve functions separately or combine them into one function.

        Conclusion

        The main thing I want you to take away from this post is the ease of authoring a workflow, how state machines play a supporting role, what an event sourced workflow could look like, and a glimpse at the mechanics. I've not gone into what transactions look like, how scheduling messages to oneself would work, how we'd tame the dragon called versioning, nor how monitoring and telemetry fit into all of this. I've been sitting on this for some time now and felt that it was better to publish as is and tackle those bits in a subsequent post.

        Credits

        • Some astute reader will notice a striking resemblance with the work that Jérémie Chassaing has been putting forth. I've adopted most of the terminology where I could. His work is at a higher level of abstraction and has broader applicability than what I'm proposing here. I'm probably straying here and there, as I didn't come to the workflow pattern thru the lense of the decider pattern.
        • The workflow command names used here were heavily inspired by what NServiceBus offers via its message handler context. In a way, you could say that I've turned the methods into messages, channeling my inner Alan Kay.
        • Special thanks to Antonios Klimis and Oskar Dudycz for taking the time to review.