← Working with agents
Technique Using AI as a tool

Draft directory

Read every line by typing it.

An agent can finish a file faster than you can read it. Copy it into the tree and it compiles, the tests pass, and nobody has read a line. Type it in from a draft instead, and every line passes through your hands on the way, which is exactly where you decide whether it is right. Let’s set up the directory and type one real file through it.

The habit to keep

Let the agent finish one file in drafts/, at the path where it will live, with a first line saying where it came from. Type it into the tree yourself. Then delete the draft.

TypeScriptGo
01 / Decide whether it earns typing

Type the files whose shape you are still deciding.

“Always type” is a rule that gets broken and then ignored. The rule these examples come from splits the work honestly, and the split is worth having before the directory is.

DRAFTS.md §6 · when typing earns its place
TYPE     a file whose shape you are still deciding
         a file in a language you are learning
         anything you would otherwise accept without reading
         the first of a kind — a first adapter, a first use case

COPY     generated code
         vendored code
         the second adapter that mirrors the first
         anything where reading it again teaches nothing

A file you do not want to type is often a file you do not want, and noticing that is most of the value. Friction on something you already understand is only cost, and a practice that pretends otherwise becomes something to route around.

Practice

Type it, or copy it?

Decide before you compare. The friction of typing is the filter, not the goal.

The first adapter for a service this codebase has never called

Your move
Leave this step with

One file worth typing, and the reason it earns it.

02 / Put it where it will live

Mirror the destination, and let nothing depend on the draft.

A draft’s path is its destination with drafts/ in front. drafts/src/lib/app/return-to.ts becomes src/lib/app/return-to.ts. That is not tidiness. It makes the question “where does this go?” get answered before the file exists, and that is the question most often answered badly and last.

The directory is ignored by git, and nothing imports it, builds it, or tests it. A draft that something depends on has become part of the code without anyone deciding it should. This is the ignore rule from the template the examples use:

.gitignore
# A draft is not an artefact — DRAFTS.md. One that outlives its session wanted to
# be a decision record or a note.
/drafts/
Leave this step with

A file at drafts/ plus its destination, and nothing in the build that can see it.

03 / Make it work first

A draft compiles, is formatted, and passes its tests before it lands.

This is the difference between a draft and a sketch. A file that already works is one you can read for whether it is right. A file that does not is one you read for whether it runs, and that is a shallower kind of attention.

Here is that difference in a real tree. On 6 September, a project’s drafts/ held a logger package. It built, and go vet passed. In two of its files, logger.go and console.go, 25 functions were panic("not implemented").

Observed

Placed over a copy of the tree, the draft fails the package’s own tests on the first call to New. The typed file passes them. It is 147 lines where the draft was 77.

What it establishes

The draft had a shape and no behavior. Whoever typed it spent the typing making it run, not reading something that already did.

The rungo 1.27.1 · the draft, then the typed file
the draft against the real tests · 12 Sep 2026
# go go1.27.1 · Darwin 25.3.0 · recorded 2026-09-12T20:45:47Z
# overwatch: drafts/overwatch-backend/pkg/logger (6 Sep 2026 09:40-09:48) placed over a copy of the tree, tests unchanged

$ grep -c "panic(\"not implemented\")" pkg/logger/{logger,console}.go   # the draft
pkg/logger/logger.go:10
pkg/logger/console.go:15

$ go build ./pkg/logger/   # the draft
build ok

$ go vet ./pkg/logger/   # the draft
vet ok

$ go test -count=1 ./pkg/logger/   # the draft, against the tests the typed file ships with
--- FAIL: TestLevelIsAFloorNotAThresholdToExceed (0.00s)
    --- FAIL: TestLevelIsAFloorNotAThresholdToExceed/a_record_at_the_configured_level_is_emitted (0.00s)
panic: not implemented [recovered, repanicked]

goroutine 36 [running]:
testing.tRunner.func1.2({0x100a3efe8, 0x100a0c410})
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2123 +0x1a0
testing.tRunner.func1()
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2126 +0x2c8
panic({0x100a3efe8?, 0x100a0c410?})
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/panic.go:859 +0x120
github.com/0xsj/overwatch-backend/pkg/logger.New(...)
	<run>/sketch/pkg/logger/logger.go:46
github.com/0xsj/overwatch-backend/pkg/logger_test.console(0x3272adc09708?, 0x10078e6d0?, 0x3d?)
	<run>/sketch/pkg/logger/logger_test.go:27 +0x60
github.com/0xsj/overwatch-backend/pkg/logger_test.TestLevelIsAFloorNotAThresholdToExceed.func6(0x3272adc5c488)
	<run>/sketch/pkg/logger/logger_test.go:48 +0x3c
testing.tRunner(0x3272adc5c488, 0x3272adc1c6c0)
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2193 +0xc4
created by testing.(*T).Run in goroutine 35
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2258 +0x3b8
FAIL	github.com/0xsj/overwatch-backend/pkg/logger	0.328s
FAIL

$ go test -count=1 ./pkg/logger/   # the typed file
ok  	github.com/0xsj/overwatch-backend/pkg/logger	0.319s
The draft and the typed filelogger.go, both versions
drafts/overwatch-backend/pkg/logger/logger.go · 6 Sep 2026
package logger

import (
	"context"
	"io"
	"log/slog"
	"time"
)

const (
	FieldError   = "error"
	FieldErrKind = "err_kind"
)

type Clock interface {
	Now() time.Time
}

type ContextAttrs func(ctx context.Context) []slog.Attr

type Format uint8

const (
	FormatConsole Format = iota
	FormatJSON
)

type ColorMode uint8

const (
	ColorAuto ColorMode = iota
	ColorNever
	ColorAlways
)

type Config struct {
	Level   slog.Level
	Format  Format
	Output  io.Writer
	Color   ColorMode
	Source  bool
	Clock   Clock
	Context ContextAttrs
}

func New(cfg Config) *slog.Logger { panic("not implemented") }

func Nop() *slog.Logger { panic("not implemented") }

func ParseLevel(s string) (slog.Level, error) { panic("not implemented") }

func WithContext(h slog.Handler, attrs ContextAttrs) slog.Handler { panic("not implemented") }

func errorPair(a slog.Attr) ([]slog.Attr, bool) { panic("not implemented") }

func replaceAttr(clk Clock) func(groups []string, a slog.Attr) slog.Attr {
	panic("not implemented")
}

type contextHandler struct {
	inner slog.Handler
	attrs ContextAttrs
}

var _ slog.Handler = contextHandler{}

func (h contextHandler) Enabled(ctx context.Context, l slog.Level) bool {
	panic("not implemented")
}

func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
	panic("not implemented")
}

func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { panic("not implemented") }

func (h contextHandler) WithGroup(name string) slog.Handler { panic("not implemented") }
overwatch-backend/pkg/logger/logger.go
package logger

import (
	"context"
	"io"
	"log/slog"
	"os"
	"strings"
	"time"

	"github.com/0xsj/overwatch-backend/pkg/errors"
)

const (
	FieldError   = "error"
	FieldErrKind = "err_kind"
)

type Clock interface {
	Now() time.Time
}

type ContextAttrs func(ctx context.Context) []slog.Attr

type Format uint8

const (
	FormatConsole Format = iota
	FormatJSON
)

type ColorMode uint8

const (
	ColorAuto ColorMode = iota
	ColorNever
	ColorAlways
)

type Config struct {
	Level   slog.Level
	Format  Format
	Output  io.Writer
	Color   ColorMode
	Source  bool
	Clock   Clock
	Context ContextAttrs
}

func New(cfg Config) *slog.Logger {
	if cfg.Clock == nil {
		panic("logger: New with a nil Clock")
	}
	if cfg.Output == nil {
		cfg.Output = os.Stdout
	}

	var h slog.Handler
	switch cfg.Format {
	case FormatJSON:
		h = slog.NewJSONHandler(cfg.Output, &slog.HandlerOptions{
			Level:       cfg.Level,
			AddSource:   cfg.Source,
			ReplaceAttr: replaceAttr(cfg.Clock),
		})
	default:
		h = newConsole(cfg)
	}
	return slog.New(WithContext(h, cfg.Context))
}

func Nop() *slog.Logger { return slog.New(slog.DiscardHandler) }

func ParseLevel(s string) (slog.Level, error) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "debug":
		return slog.LevelDebug, nil
	case "info", "":
		return slog.LevelInfo, nil
	case "warn", "warning":
		return slog.LevelWarn, nil
	case "error":
		return slog.LevelError, nil
	}
	return 0, errors.Newf(errors.Invalid, "unknown log level %q", s)
}

func WithContext(h slog.Handler, attrs ContextAttrs) slog.Handler {
	if attrs == nil {
		return h
	}
	return contextHandler{inner: h, attrs: attrs}
}

func errorPair(a slog.Attr) ([]slog.Attr, bool) {
	if a.Value.Kind() != slog.KindAny {
		return nil, false
	}
	err, ok := a.Value.Any().(error)
	if !ok || err == nil {
		return nil, false
	}
	return []slog.Attr{
		slog.String(FieldError, err.Error()),
		slog.String(FieldErrKind, errors.KindOf(err).String()),
	}, true
}

func replaceAttr(clk Clock) func(groups []string, a slog.Attr) slog.Attr {
	return func(groups []string, a slog.Attr) slog.Attr {
		if len(groups) == 0 && a.Key == slog.TimeKey {
			return slog.Time(slog.TimeKey, clk.Now())
		}
		if pair, ok := errorPair(a); ok {
			return slog.Group("", pair[0], pair[1])
		}
		return a
	}
}

type contextHandler struct {
	inner slog.Handler
	attrs ContextAttrs
}

var _ slog.Handler = contextHandler{}

func (h contextHandler) Enabled(ctx context.Context, l slog.Level) bool {
	return h.inner.Enabled(ctx, l)
}

func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
	if attrs := h.attrs(ctx); len(attrs) > 0 {
		r.AddAttrs(attrs...)
	}
	return h.inner.Handle(ctx, r)
}

func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	h.inner = h.inner.WithAttrs(attrs)
	return h
}

func (h contextHandler) WithGroup(name string) slog.Handler {
	h.inner = h.inner.WithGroup(name)
	return h
}

When an agent hands you stubs, it has handed you a sketch. A sketch is fine; it is not this. Call it what it is, or send it back with the tests it has to pass. What to avoid is typing a sketch as if it were a draft.

Leave this step with

A draft that has already passed, somewhere else, the tests it will face in the tree.

04 / Say where it came from

One line at the top: authored, written against something, or copied.

Three different things arrive in drafts/, and they look identical in a directory listing. Only the first line tells them apart, and only the author knows which it is.

DRAFTS.md §3 · a draft names where it came from
DRAFT · authored — no existing version; written against this tree's decisions
DRAFT · against <path or repo> — read, not copied; <what differs and why>
DRAFT · copied from <path or repo> — unchanged

The copied one is the one to watch. A file lifted from another project carries that project’s decisions: its error vocabulary, its conventions, its shape. Typed into a tree that made none of those decisions, the tree now has them, and nothing says so.

A rule does not apply itself, though. The provenance line was added to this rule on 4 September. Every code draft in the two directories that hold code was checked for it: 24 files, none with the line, not even the 10 written after the rule gained it.

The survey24 files, one command
every code draft · recorded 12 Sep 2026
# recorded 2026-09-12T20:46:56Z · every code file in the two drafts/ directories that hold code
# DRAFTS.md §3 (the provenance line) was committed to conduit on 2026-09-04

$ for f in $(find overwatch/drafts archive/overwatch-v1/drafts -type f \( -name "*.go" -o -name "*.ts" \) | sort); do
    head -3 "$f" | grep -q "DRAFT ·" && echo "line  $f" || echo "none  $f"; done
none  2026-08-29  archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/clock.go
none  2026-08-29  archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/clock_test.go
none  2026-08-29  archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/wait.go
none  2026-08-29  archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/wait_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/id/generator_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/id/id_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/cursor.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/cursor_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/pagination.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/pagination_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/random/derive_test.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/random/predictable.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/random/random.go
none  2026-08-30  archive/overwatch-v1/drafts/overwatch-backend/pkg/random/random_test.go
none  2026-09-06  overwatch/drafts/overwatch-backend/cmd/server/main.go
none  2026-09-06  overwatch/drafts/overwatch-backend/pkg/logger/console.go
none  2026-09-06  overwatch/drafts/overwatch-backend/pkg/logger/doc.go
none  2026-09-06  overwatch/drafts/overwatch-backend/pkg/logger/logger.go
none  2026-09-06  overwatch/drafts/provenance/actor.go
none  2026-09-06  overwatch/drafts/provenance/context.go
none  2026-09-06  overwatch/drafts/provenance/identifier.go
none  2026-09-06  overwatch/drafts/provenance/marshal.go
none  2026-09-06  overwatch/drafts/provenance/origin.go
none  2026-09-06  overwatch/drafts/provenance/provenance.go

24 files · 0 with a provenance line

The draft in the next step says what it is. It is this site’s return-path check, written after reading a starter template’s version, and the line names the two ways it differs.

Leave this step with

A first line that says which of the three the file is, and, when it was written against something, what differs.

05 / Type it, reading every line

The typing is the point, so do it for real.

Open the draft beside the destination and type. Do not paste. The places you would have skimmed are the places you now have to stop at: why the protocol-relative check comes first, why the fallback is a slash and not the page you were on. Try to answer both before you read on.

Type it in

Type the draft into the tree, then compare.

This is this site’s real return-path check, as a draft. Type it into the box without pasting, then compare. Tab moves focus out of the box, so indent with spaces; the comparison counts spacing apart from changed lines, so that difference is expected. Nothing is saved.

drafts/src/lib/app/return-to.ts
// DRAFT · against blueprints/flover-svelte/src/lib/app/return-to.ts — read, not copied; falls back to '/' and refuses /auth and /api, where that one allows only /app and /cookbook

const FALLBACK = '/';

export function safeReturnTo(value: unknown): string {
	if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//'))
		return FALLBACK;
	try {
		const target = new URL(value, 'https://heyrian.invalid');
		if (target.origin !== 'https://heyrian.invalid') return FALLBACK;
		const path = decodeURIComponent(target.pathname);
		if (/^\/(?:auth|api)(?:\/|$)/.test(path)) return FALLBACK;
		if (/[\\\s]/.test(path)) return FALLBACK;
		return `${target.pathname}${target.search}${target.hash}`;
	} catch {
		return FALLBACK;
	}
}
Line 1 says

against· blueprints/flover-svelte/src/lib/app/return-to.ts

A new file informed by blueprints/flover-svelte/src/lib/app/return-to.ts. Check that what differs is what the line says differs.

Type the file, then compare.

It compares text. It cannot tell you whether a changed line is an improvement, or what the typing taught you.

The answers the typing should have raised: //example.com starts with a slash, so a check that only asked for a leading slash would pass it, and the URL parser would then send the reader to another site. Refusing // first means the parser only ever sees a local path. The fallback is / because the rejected value was the page offered, and a check that has refused it has nothing else it can trust; the site root always exists. On this site the caller that finishes sign-in now turns that / into /account, outside the lines you typed.

What does a clean comparison establish? Less than it seems. Here is a real pair from 29 August: a clock package, drafted at 21:08 and typed in by 21:49, going by file times. Both versions pass their tests with the race detector on. In clock.go the comparison finds 0 changed lines and 4 spacing differences: a pair of one-line functions realigned, blank lines dropped and added. The same recording shows one more added blank line in wait.go, and nothing else.

That is the trace hands leave, and a copy would leave none. It is also everything a diff can show. It cannot show what the reading taught. If the typing taught you something, that belongs in a note, not in the draft.

The clock, draft against typeddiff, gofmt, and both test runs
draft against typed · recorded 12 Sep 2026
# go go1.27.1 · Darwin 25.3.0 · recorded 2026-09-12T20:45:37Z
# archive/overwatch-v1: drafts/overwatch-backend/pkg/clock (29 Aug 2026 21:08) vs overwatch-backend/pkg/clock (typed 21:47 and 21:49)

$ diff -u draft/pkg/clock/clock.go typed/pkg/clock/clock.go
--- draft/pkg/clock/clock.go
+++ typed/pkg/clock/clock.go
@@ -14,8 +14,7 @@
 
 type System struct{}
 
-func (System) Now() time.Time { return time.Now().UTC() }
-
+func (System) Now() time.Time         { return time.Now().UTC() }
 func (System) Elapsed() time.Duration { return time.Since(origin) }
 
 type Fake struct {
@@ -44,6 +43,7 @@
 func (f *Fake) Advance(d time.Duration) time.Time {
 	if d < 0 {
 		panic("clock: Fake.Advance with a negative duration; use Set to move the wall clock backwards")
+
 	}
 	f.mu.Lock()
 	defer f.mu.Unlock()
@@ -60,7 +60,6 @@
 }
 
 func Since(c Clock, t time.Time) time.Duration { return c.Now().Sub(t) }
-
 func Until(c Clock, t time.Time) time.Duration { return t.Sub(c.Now()) }
 
 var (

$ gofmt -l draft/pkg/clock typed/pkg/clock   # files gofmt would change

$ diff <(gofmt draft/pkg/clock/clock.go) <(gofmt typed/pkg/clock/clock.go) && echo identical-after-gofmt
17,18c17
< func (System) Now() time.Time { return time.Now().UTC() }
< 
---
> func (System) Now() time.Time         { return time.Now().UTC() }
46a46
> 
63d62
< 
$ diff <(gofmt draft/pkg/clock/wait.go) <(gofmt typed/pkg/clock/wait.go) && echo identical-after-gofmt
55a56
> 

$ (cd draft && go test -count=1 -race ./pkg/clock/)
ok  	github.com/0xsj/overwatch-backend/pkg/clock	1.476s
$ (cd typed && go test -count=1 -race ./pkg/clock/)
ok  	github.com/0xsj/overwatch-backend/pkg/clock	1.400s
Leave this step with

The file in the tree, typed, and anything it taught you written down.

06 / Delete the draft

Once the file exists, the draft goes.

The directory that held that clock kept a README. Its status column says: “Typed out. Draft kept for reference.”

drafts/README.md · 30 Aug 2026
# drafts

**Nothing here is real.** Files mirror their destination path under
`overwatch-backend/`, so `drafts/overwatch-backend/pkg/clock/clock.go` is typed
out at `overwatch-backend/pkg/clock/clock.go`.

Drafts are never committed and are usually deleted once typed. The point is to
type the file, not to copy it.

Every draft here was built, vetted, gofmt'd and tested in a scratch module before
being placed — so it compiles as written, but it is a proposal, not an artifact.

| Draft | Status |
| --- | --- |
| `overwatch-backend/pkg/clock/` | Typed out. Draft kept for reference. |
| `overwatch-backend/pkg/id/` | Typed out. Draft kept for reference. |
| `overwatch-backend/pkg/random/` | `random.go` + `derive.go` + two test files. 23 tests, 100% coverage, race-clean. No `doc.go` and no comments yet. |

The rule written a few days later says the opposite, and its reason is worth keeping: two copies of a file, one of which nothing checks, and no way to tell which is current. Keeping a typed draft for reference is how the directory rots.

A small command in the owner’s workspace, conduit draft, holds the order: it seeds the first line, lists what is waiting, and refuses to remove a draft while its destination does not exist, because a draft deleted before the file is typed is an hour traded for nothing. You will not have that command; a shell alias or a short script can do the same three things.

conduit draft · recorded 12 Sep 2026
# conduit 72c06e3 2026-09-05 · bash 5.3.15(1)-release · recorded 2026-09-12T20:50:03Z
# a scratch workspace with an empty drafts/; ANSI colour removed

$ conduit draft src/lib/app/return-to.ts

  drafts/src/lib/app/return-to.ts

  say where it came from on line 1 — authored, against, or copied
  vetted before it is placed: it compiles, it is formatted, its tests pass

$ head -2 drafts/src/lib/app/return-to.ts
// DRAFT · authored | against <ref> | copied from <ref>
// Delete this line when the file is typed in. DRAFTS.md §3.

$ conduit draft --done src/lib/app/return-to.ts   # before the file exists
src/lib/app/return-to.ts does not exist yet — type it first
[exit 1]

# stand-in for typing: a three-line file written with printf, so that the destination exists
$ conduit draft --list

  waiting to be typed

  ~ src/lib/app/return-to.ts  destination exists — typed?

  conduit draft --done <path>  once it is typed

$ conduit draft --done src/lib/app/return-to.ts
  typed · removed drafts/src/lib/app/return-to.ts
[exit 0]

The typing in this recording is a stand-in: a three-line file written by a script so that the destination exists. The seeded line, the refusal, and the removal are real output.

A draft that outlives the session that wrote it is a signal, not a state. It wanted to be a decision record or a note, and it should become one.

Start yours

The smallest drafts directory worth having.

Ignore it
/drafts/ in .gitignore, and nothing imports from it.
Path
The destination, with drafts/ in front.
Line 1
Authored, against a reference, or copied, and what differs.
Before it lands
It compiles, it is formatted, and its tests pass somewhere else.
Then
Type it, delete the draft, and note what the typing taught.

Where each quote comes from: the rule and both packages are copies of the originals, dated as they were written (the clock on 29–30 August, the logger on 6 September); the runs against them and the command recording were made on 12 September 2026. One thing is a stand-in, and its caption says so: the typing inside the command recording. The lab’s draft is this site’s own return-path check, read from the file at build time.

Take the idea with you

Explain the habit without saying “draft directory.”

“The agent writes the whole file somewhere nothing can import it. I make it pass there, then type it into its real place and throw the other copy away.” That is the practice. The name is what you call the folder.

Before moving on, explain three things without the name: why a file full of stubs is not worth typing, what the first line of a draft is for, and why a typed draft is deleted rather than kept for reference.

Connections to follow nextRelated lessons
  • Notes protocol keeps what the typing taught you, as one sentence you could be wrong about.
  • Spec before code gives the draft’s tests a source of truth the agent did not write.
  • Enforcement layer is the check that says no when nobody is reading the diff.

Add /drafts/ to your .gitignore today. The next time an agent hands you a file whose shape you are still deciding, put it there with a first line, and type it in.

Back to working with agents →