This morning, I published What Is an Agent Harness? and built a tiny one in TypeScript.
That harness waits for a task, runs, and stops.
Yesterday, OpenAI launched an agent that doesn't wait.
Dots.
- On September 29, at DevDay 2026, OpenAI introduced dots, "always-on agents" with their own cloud computer and browser. When you aren't working with one, it does "proactive research" with tools "restricted to be read-only." Custom Rules let you allow, require approval for, or block actions. Progress shows up in an Activity View.
- On September 28, SpaceXAI (xAI) announced Team Bots: shared Grok Bots built around "a role or workflow." Each Bot keeps separate context and memories per user while sharing skills across the team.
- On September 29, Meta introduced Muse for Small Business with new connectors. Meta says "nothing publishes, sends, or spends without your approval."
- Also on September 29, OpenAI's DevDay recap said ChatGPT now supports "the proposed MCP Events specification," so apps can trigger automations. The experimental design sketch, merged September 8, describes poll, push and webhook delivery.
Different products.
Same shape.
Something happens.
The agent wakes up.
Rules decide what it's allowed to do about it.
On September 19, in The Computer Is Becoming an API for AI, I predicted that 2027 is when agents get identities: "Dedicated accounts, credentials, budgets, permissions and audit trails become normal."
That was prediction, not history.
But specialist dots (in preview) get their own identity and credentials. An early, partial version is already here.
So let's build a tiny dot.
By the end, you'll run one command:
npx tsx dot.ts
And watch an agent wake on events, stay read-only in the background, hold a message for approval, and block two actions.
No API key.
No real model.
Just TypeScript.
One honesty note: this is not how OpenAI built dots. It's my small model of the ideas they described in public.
Table of Contents
- What We Are Building
- Project Setup
- Step 1: Simulate the Event Feed
- Step 2: Split Background and Assigned Mode
- Step 3: Add Custom Rules
- Step 4: Give Each User Their Own Memory
- Step 5: Record an Activity Timeline
- Step 6: Wire Up the Dot
- Step 7: Mock the Model and Run It
- Where It Breaks Down
- The Bigger Idea
What We Are Building
It's also a small version of the architecture behind Roster.
The example is a tiny team assistant. Ana, Ben and their files are made up.
Project Setup
You will need Node.js 18 or newer.
mkdir tiny-dot
cd tiny-dot
npm init -y
npm install --save-dev typescript tsx @types/node
Save the following blocks, in order, as dot.ts.
Step 1: Simulate the Event Feed
type AppEvent = {
at: string;
user: string;
type: "calendar.updated" | "message.received" | "task.created";
delivery: "poll" | "push";
payload: string;
assigned: boolean;
};
class EventFeed {
private queue: AppEvent[] = [];
private listeners: ((event: AppEvent) => void)[] = [];
subscribe(listener: (event: AppEvent) => void) {
this.listeners.push(listener);
}
push(event: AppEvent) {
for (const listener of this.listeners) listener(event);
}
enqueue(event: AppEvent) {
this.queue.push(event);
}
poll() {
const batch = this.queue;
this.queue = [];
for (const event of batch) this.push(event);
}
}
push means the app tells us right now. poll means we check a queue on our own schedule.
Both end at the same listener. The agent doesn't care how it was woken.
The assigned flag says whether a person handed this over.
Step 2: Split Background and Assigned Mode
type Mode = "background" | "assigned";
type Tool = {
name: string;
writes: boolean;
run: (input: string) => string;
};
const tools: Tool[] = [
{ name: "read_calendar", writes: false, run: (u) => `2 events today for ${u}` },
{ name: "search_docs", writes: false, run: (q) => `found "${q}.pdf"` },
{ name: "send_message", writes: true, run: (m) => `sent: ${m}` },
{ name: "delete_file", writes: true, run: (f) => `deleted ${f}` },
{ name: "change_password", writes: true, run: () => "password changed" },
];
const registry = new Map(tools.map((tool) => [tool.name, tool]));
const modeFor = (event: AppEvent): Mode =>
event.assigned ? "assigned" : "background";
Every tool says whether it writes.
The mode comes from the event, not from the model.
That distinction matters.
A model can argue its way into "this is urgent."
An event type can't.
Background mode is my version of OpenAI's rule: in proactive research, a dot can't send messages, change app content, or control your browser or computer.
Step 3: Add Custom Rules
type Decision = "allow" | "ask" | "deny";
const customRules: Record<string, Decision> = {
read_calendar: "allow",
search_docs: "allow",
send_message: "ask",
delete_file: "deny",
};
const alwaysHuman = new Set(["change_password"]);
function decide(toolName: string, mode: Mode): { decision: Decision; reason: string } {
const tool = registry.get(toolName);
if (!tool) return { decision: "deny", reason: "unknown tool" };
if (alwaysHuman.has(toolName)) {
return { decision: "deny", reason: "always stays with you" };
}
if (mode === "background" && tool.writes) {
return { decision: "deny", reason: "background is read-only" };
}
const decision = customRules[toolName] ?? "ask";
return { decision, reason: `custom rule: ${decision}` };
}
The order of the checks is the policy.
- Some actions always stay with a human.
- Background mode blocks every write.
- Then the user's custom rule applies.
- Anything without a rule falls back to
ask.
An unknown action should cost you a click, not an incident.
The alwaysHuman set mirrors a line in OpenAI's announcement: "Certain sensitive tasks, such as changing a password, always stay with you."
No custom rule can override it.
Step 4: Give Each User Their Own Memory
class Memory {
private byUser = new Map<string, string[]>();
readonly sharedSkills = ["summarize in one line"];
recall(user: string) {
return this.byUser.get(user) ?? [];
}
remember(user: string, fact: string) {
this.byUser.set(user, [...this.recall(user), fact]);
}
}
One Map entry per user. One shared list of skills.
That's the Team Bots split in about ten lines.
Ben's request never shows up in Ana's context.
Not because the model is polite.
Because it's a different key.
Step 5: Record an Activity Timeline
type Activity = {
at: string;
user: string;
mode: Mode;
kind: "wake" | "ran" | "held" | "blocked" | "approved";
detail: string;
};
class ActivityView {
private entries: Activity[] = [];
add(entry: Activity) {
this.entries.push(entry);
}
print() {
for (const e of this.entries) {
const cols = [e.at, e.user.padEnd(4), e.mode.padEnd(11), e.kind.padEnd(9)];
console.log(`${cols.join(" ")}${e.detail}`);
}
}
}
Every wake, run, hold, block and approval becomes an entry. It's the text version of an Activity View.
An always-on agent without an activity log is just a very confident cron job.
Step 6: Wire Up the Dot
type Proposal = { tool: string; input: string };
type ModelTurn = { proposals: Proposal[]; remember?: string };
type Model = (event: AppEvent, memory: string[], skills: string[]) => ModelTurn;
type Held = { event: AppEvent; proposal: Proposal };
function createDot(model: Model) {
const memory = new Memory();
const activity = new ActivityView();
const held: Held[] = [];
function handle(event: AppEvent) {
const mode = modeFor(event);
const base = { at: event.at, user: event.user, mode };
activity.add({ ...base, kind: "wake", detail: `${event.type} via ${event.delivery}` });
const turn = model(event, memory.recall(event.user), memory.sharedSkills);
for (const proposal of turn.proposals) {
const call = `${proposal.tool}(${proposal.input})`;
const { decision, reason } = decide(proposal.tool, mode);
if (decision === "allow") {
const result = registry.get(proposal.tool)!.run(proposal.input);
activity.add({ ...base, kind: "ran", detail: `${call} -> ${result}` });
} else if (decision === "ask") {
held.push({ event, proposal });
activity.add({ ...base, kind: "held", detail: `${call} waiting for approval` });
} else {
activity.add({ ...base, kind: "blocked", detail: `${call} ${reason}` });
}
}
if (turn.remember) memory.remember(event.user, turn.remember);
}
function approve(index: number, at: string) {
const [{ event, proposal }] = held.splice(index, 1);
const result = registry.get(proposal.tool)!.run(proposal.input);
const detail = `${proposal.tool} -> ${result}`;
activity.add({ at, user: event.user, mode: modeFor(event), kind: "approved", detail });
}
return { handle, approve, memory, activity, held };
}
handle is the whole agent.
The model sees one user's memory and the shared skills. It only proposes.
Each proposal goes through decide. allow runs now. ask is held for a human. deny is logged.
Held actions wait until someone calls approve, which gets its own timeline entry.
Step 7: Mock the Model and Run It
const mockModel: Model = (event, memory) => {
if (event.type === "calendar.updated") {
return {
proposals: [
{ tool: "read_calendar", input: event.user },
{ tool: "send_message", input: "team: review moved" },
],
remember: event.payload,
};
}
if (event.type === "message.received") {
const proposals = [{ tool: "search_docs", input: "q3-notes" }];
return { proposals, remember: `asked for: ${event.payload}` };
}
if (event.payload.includes("password")) {
const proposals = [
{ tool: "delete_file", input: "old-notes.md" },
{ tool: "change_password", input: "notion" },
];
return { proposals };
}
const known = memory.find((fact) => fact.includes("review")) ?? "no update";
return { proposals: [{ tool: "send_message", input: `team: ${known}` }] };
};
const event = (
at: string,
user: string,
type: AppEvent["type"],
delivery: AppEvent["delivery"],
payload: string,
assigned = false,
): AppEvent => ({ at, user, type, delivery, payload, assigned });
const feed = new EventFeed();
const dot = createDot(mockModel);
feed.subscribe(dot.handle);
feed.enqueue(event("08:00", "ana", "calendar.updated", "poll", "design review is now at 2 PM"));
feed.enqueue(event("08:05", "ben", "message.received", "poll", "Q3 notes"));
feed.poll();
feed.push(event("09:00", "ana", "task.created", "push", "tell the team the new review time", true));
feed.push(event("09:10", "ana", "task.created", "push", "clean up notes and reset my Notion password", true));
dot.approve(0, "09:15");
console.log("Activity View");
dot.activity.print();
console.log("\nMemory");
console.log(" ana:", dot.memory.recall("ana"));
console.log(" ben:", dot.memory.recall("ben"));
console.log(" held for approval:", dot.held.length);
The mock model is a script. The rules don't care how a proposal was made.
Run it:
npx tsx dot.ts
You should see:
Activity View
08:00 ana background wake calendar.updated via poll
08:00 ana background ran read_calendar(ana) -> 2 events today for ana
08:00 ana background blocked send_message(team: review moved) background is read-only
08:05 ben background wake message.received via poll
08:05 ben background ran search_docs(q3-notes) -> found "q3-notes.pdf"
09:00 ana assigned wake task.created via push
09:00 ana assigned held send_message(team: design review is now at 2 PM) waiting for approval
09:10 ana assigned wake task.created via push
09:10 ana assigned blocked delete_file(old-notes.md) custom rule: deny
09:10 ana assigned blocked change_password(notion) always stays with you
09:15 ana assigned approved send_message -> sent: team: design review is now at 2 PM
Memory
ana: [ 'design review is now at 2 PM' ]
ben: [ 'asked for: Q3 notes' ]
held for approval: 0
Same model.
Different modes.
Different outcomes.
Where It Breaks Down
This is a teaching dot. Here is what a real one would need.
Events Arrive Twice
There is no de-duplication. The same event twice means the same work twice.
Real event systems need IDs, ordering and replay rules.
Read-Only Isn't Harmless
Background mode can't write. It can still read Ana's calendar.
Reading private data is a privacy decision too.
The Bigger Idea
The harness post was about what happens inside a run.
This one is about what starts a run, and what the agent may do when nobody asked.
There was a counterweight too. On September 28, CBS News reported that OpenAI held back GPT-6.1 Astra because it "didn't quite meet the bar in terms of staying within scope and authorization," quoting Saachi Jain. That's a press report, not an OpenAI post.
I think the pattern is clear anyway. Authorization is becoming the product surface.
┌──────────────────────────────────────┐
│ Always-on agent │
│ │
│ Event ──→ Mode ──→ Model ──→ Rules │
│ ↑ ↓ │
│ Memory run/hold │
│ ↓ │
│ Activity View ←────┘ │
└──────────────────────────────────────┘
Events provide wake-ups.
Modes provide defaults.
Rules provide boundaries.
Memory provides continuity.
The model provides reasoning.
The activity view provides evidence.
And the lane provides a reason to wake up at all.
Always-on is the easy part. Always-allowed is the hard part.
Try Roster
I'm building Roster around this idea: AI employees with real responsibilities, tools, memory, schedules and computer access. They wake up when something happens, work inside a lane, and ask before doing anything you'd want to see first.
If the same follow-ups, handoffs, and waiting loops keep eating your week, give them to an AI employee.



Top comments (0)