Writing principles
Become an expert
An expert is anyone who publishes principles. This guide takes you from an idea to a released principle that runs in other people's agents, step by step.
What you need#
- A GitHub account, to sign in.
- One piece of advice you keep giving in code reviews.
- Twenty minutes.
You can do every step in the browser, with nothing installed. Your coding agent can do the same steps in your own editor, through the MCP tools of the plugin. Both produce the same principle, and you can switch between them at any point.
1. Pick one piece of advice#
Good principles share three things:
- One thing to do. "Use clock.now() instead of DateTime.now()" is a principle. "Write testable code" is a wish.
- A moment it applies. The detector has to notice it in what the agent writes, reads, is asked or runs. If you cannot say what that looks like, the agent will hear it everywhere or never.
- A reason. An agent follows advice better when it knows why, and so do people reading your page.
This guide builds a simple version of use-clock from Pascal's own principles: in Dart code, read the time through clock.now() so tests can control it.
2. Sign in#
Sign in with GitHub.
Your principles will be published as <your handle>/<name>.
3. Create the principle#
In the browser: choose Create principle in the Library, or under My principles on your profile (your handle at the top right).
The editor opens with a starter that already passes its tests.
Give it the name use-clock.
The name cannot change afterwards.
With your agent: building a principle needs esbuild, which comes with the SDK. Install it once in the repository that will hold your principles, with Node 22.18 or newer:
npm install --save-dev github:passsy/wellactually#sdk vitestThen ask your agent to create a principle named use-clock.
It uses scaffold, which creates a folder that already passes its tests.
Write with an agent shows the whole loop.
Either way you get the same three files:
| File | What it is |
|---|---|
principle.md | The advice, in plain markdown. This is what the agent reads. |
detector.ts | When the advice applies. It reacts to events and reports what it found. |
detector.test.ts | What the detector fires on, and what it stays quiet on. |
4. Write the advice#
Replace principle.md with this:
Use clock.now() instead of DateTime.now()
Read the time through clock.now() from package:clock, so tests can control it with fakeAsync or withClock.
DateTime.now() always returns the time of the machine. A test cannot move it, so code that reads it is tested at whatever time the test happens to run.
clock.now() from package:clock returns the same time in production, and a test can fix it:
dart
withClock(Clock.fixed(DateTime(2024, 1, 1)), () {
expect(isNewYear(), isTrue);
});
When it is fine
Inside package:clock itself, and in scripts that are never tested.- The
# headingis the title. - The paragraph right below it is the summary. It is shown in lists, and it is what the agent reads every time after the first. Keep it to one sentence of at most 200 characters, and say what to do instead and why.
- Everything below is the advice the agent reads the first time the principle fires in a session.
Writing the advice has more on what makes advice work for an agent.
5. Write the detector#
Replace detector.ts with this:
import { findUp, mentioned, onPrompt, onWrite } from "@wellactually/sdk/v4";
// Every Dart file a person wrote. Generated files are nobody's decision.
const DART = ["**/*.dart", "!**/*.g.dart", "!**/*.freezed.dart"];
onWrite({
globs: DART,
*detect(event) {
// Only what the agent wrote. A DateTime.now() that was already there is not this change's business.
for (const written of event.written) {
if (written.text.trim().startsWith("//")) {
continue;
}
if (written.text.includes("DateTime.now()")) {
yield { line: written.line, match: "DateTime.now()" };
}
}
},
});
onPrompt({
*detect(event) {
// A prompt says nothing about its language. A pubspec.yaml says it is Dart.
if (findUp("pubspec.yaml", event.cwd) !== null) {
yield* mentioned(event, ["DateTime.now", "fakeAsync", "withClock"]);
}
},
});The detector registers two handlers at the top level of the file:
onWriteruns when the agent wrote or edited a file that matches the globs.event.writtenholds only the lines the agent wrote, so code that was already there is not judged. Eachyieldis a finding: here the line, and the exact text that matched.onPromptruns when you send your agent a message. A prompt has no file, so the handler looks for apubspec.yamlupwards from where the session runs, and only then checks for the names.mentionedfinds a name in any case and spelling, and asks for the pointer: just the summary, because a word in a prompt does not mean anything is wrong yet.
The * in front of detect makes it a generator, which is what lets it yield.
Detectors covers all six events.
6. Write the tests#
A test builds an event, shows it to the detector and checks what it reports.
Replace detector.test.ts with this:
import { expect, test } from "vitest";
import { detect, edit, prompt, source, write } from "@wellactually/sdk/v4/test";
const greeting = source`
String greeting() {
final hour = DateTime.now().hour;
return hour < 12 ? 'Good morning' : 'Hello';
}
`;
test("fires on a DateTime.now() the agent writes", () => {
expect(detect(write("lib/greeting.dart", greeting))).toEqual([{ line: 2, match: "DateTime.now()" }]);
});
test("stays quiet on a DateTime.now() that was already there", () => {
expect(detect(edit("lib/greeting.dart", greeting, { written: " return hour < 12 ? 'Good morning' : 'Hello';" }))).toEqual([]);
});
test("stays quiet on a comment", () => {
expect(detect(write("lib/greeting.dart", "// Never call DateTime.now() here."))).toEqual([]);
});
test("stays quiet on generated code", () => {
expect(detect(write("lib/greeting.g.dart", greeting))).toEqual([]);
});
test("points at the principle when a prompt in a Dart project names fakeAsync", () => {
expect(detect(prompt("Why does my fakeAsync test still see the real time?"), { project: "fixtures/dart_app" })).toEqual([
{ match: "fakeAsync", depth: "pointer" },
]);
});
test("stays quiet on the same prompt outside a Dart project", () => {
expect(detect(prompt("Why does my fakeAsync test still see the real time?"))).toEqual([]);
});The prompt tests need a project with a pubspec.yaml.
A test only sees the files of the event, unless you give it a project from the principle's fixtures folder.
Add this file:
namedart_appIn the browser, type fixtures/dart_app/pubspec.yaml into the field below the files and choose Add.
7. Run the tests#
In the browser, choose Run tests.
Your agent uses run_tests and reports the result:
use-clock Use clock.now() instead of DateTime.now()
detector.test.ts
✓ fires on a DateTime.now() the agent writes
✓ stays quiet on a DateTime.now() that was already there
✓ stays quiet on a comment
✓ stays quiet on generated code
✓ points at the principle when a prompt in a Dart project names fakeAsync
✓ stays quiet on the same prompt outside a Dart project
Slowest detector run 2.0 ms.
Ready to publish.This builds the principle and runs its tests in the same sandbox the plugin uses on people's machines. The tests are also ordinary vitest files, see Testing.
8. Try it on a real file#
Ask your agent to try use-clock on lib/main.dart.
It uses try, which shows the file to the detector as if the agent had just written it, and returns what the agent would read:
1 findings in 1.9 ms.
The agent would be shown:
<wellactually>
write lib/main.dart: You just changed this file, so fixing it belongs in the change you are already making. ...
<principle id="local/use-clock" expert="local" title="Use clock.now() instead of DateTime.now()">
- line 2, "DateTime.now()": print(DateTime.now());
# Use clock.now() instead of DateTime.now()
...try also shows a file as read, and tries a prompt, a command or what a command printed.
9. Probe a real repository#
A detector that fires on half of all files describes the language, not a problem.
Ask your agent to probe use-clock against a real repository, such as ~/code/spot.
It uses probe, which runs the detector over every file and reports how often it fires:
Scanned 298 files, 150 are ones the detector handles, 3 fired (5 findings). Slowest run 4.5 ms.
Fires on 2% of the 150 files it looks at.
lib/src/spot/tree_snapshot.dart line 69, "DateTime.now()": timestamp: DateTime.now(),
test/util/browser/headless_chrome.dart line 102, "DateTime.now()": final deadline = DateTime.now().add(timeout);
...Read the samples. Each one should be a place where you would leave the same review comment. From 10 files on, a detector that fires on more than 50% of them fails the probe, and so does one that throws on any file. Then add quiet cases from the samples and narrow the detector.
10. Publish it#
In the browser: choose Save and release.
With your agent: sign in once with the plugin's login skill, /wellactually:login in Claude Code or $wellactually:login in Codex.
Then ask the agent to publish use-clock.
It uses publish_draft and returns the link to the released revision.
The registry builds the principle again and runs its tests before it releases it. It now runs in your own agent on every machine you sync.
11. Make it public#
It is private, unless you picked public under Who can see it in the editor. Choose Make public in the Settings tab of the principle's page, and it shows up in the Library, on your profile, and in the agents of everyone who follows you.
What happens next#
- People follow you from your profile or your card under Explore, and every principle you publish from then on runs for them too. Your profile counts your followers.
- Your advice stays yours, all rights reserved, and the detector code is MIT. Others may fork the principle and credit you, unless you turn that off. See Licences and forks.
- Each change is a new revision: edit, test, publish. Released revisions never change. Publish and release covers the whole cycle.
- Agents rate your advice as they use it.
downsays fix the advice,not_applicablesays fix the detector. See Ratings and counts. - Have more than a few principles? Keep them in a GitHub repository and every push releases what changed. See Sync from GitHub.