DEV Community

Cover image for OpenAI Launched Dots. Let's Build a Tiny One in TypeScript.
Bobby Hall Jr
Bobby Hall Jr

Posted on

OpenAI Launched Dots. Let's Build a Tiny One in TypeScript.

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
Enter fullscreen mode Exit fullscreen mode

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

  1. What We Are Building
  2. Project Setup
  3. Step 1: Simulate the Event Feed
  4. Step 2: Split Background and Assigned Mode
  5. Step 3: Add Custom Rules
  6. Step 4: Give Each User Their Own Memory
  7. Step 5: Record an Activity Timeline
  8. Step 6: Wire Up the Dot
  9. Step 7: Mock the Model and Run It
  10. Where It Breaks Down
  11. The Bigger Idea

What We Are Building

From event to activity timeline

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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.

Background mode vs assigned mode

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}` };
}
Enter fullscreen mode Exit fullscreen mode

The order of the checks is the policy.

  1. Some actions always stay with a human.
  2. Background mode blocks every write.
  3. Then the user's custom rule applies.
  4. 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.

Custom rules: allow, ask, deny, always yours

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]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The mock model is a script. The rules don't care how a proposal was made.

Run it:

npx tsx dot.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ←────┘    │
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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.

Try Roster →

Top comments (0)