Skip to main content

Build your own dot

OpenAI's dots are always-on agents with memory, their own computer, a schedule and your apps. Each part is buildable today. Here's one on Cloudflare, with the approval step that keeps it safe.

9 min read

On 29 September OpenAI launched dots: personal agents that run on GPT-6 Astra, each with its own cloud computer and browser, connected to more than 4,000 apps, learning from feedback and working while their owner does something else. They’re available to ChatGPT Pro and Business Premium subscribers, except Pro users in the EEA, Switzerland and the UK, and there’s no API for building or customising one. Anyone who typed dot.com into a browser on launch day to find out more landed on a download page for Grok Bot, SpaceXAI’s own always-on agent. The domain changed hands in July and SpaceXAI hasn’t said why. Some trolls are planned; some are just a very good domain.

A dot is built from four parts, and none of them is exclusive to OpenAI: a persistent identity that remembers, a computer of its own, a schedule that lets it act without being asked, and access to your apps behind an approval step. Each fits in a few dozen lines on Cloudflare’s Agents SDK and Sandbox SDK, which is where we host what we build, and the model behind it is one line you can change.

The shape of it

The whole dot is one Durable Object, a small stateful server with its own storage, plus a container it can run commands in. The configuration declares both, and Workers AI for the model:

// wrangler.jsonc
{
  "name": "my-dot",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "compatibility_flags": ["nodejs_compat"],
  "ai": { "binding": "AI" },
  "containers": [
    {
      "class_name": "Sandbox",
      "image": "./Dockerfile",
      "instance_type": "lite",
      "max_instances": 5,
    },
  ],
  "durable_objects": {
    "bindings": [
      { "name": "Dot", "class_name": "Dot" },
      { "name": "Sandbox", "class_name": "Sandbox" },
    ],
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Dot", "Sandbox"] }],
}

The Dockerfile is one line, FROM docker.io/cloudflare/sandbox:next-python, which adds Python to the Node and Bun the base image ships with, and the Worker’s entry point routes requests to the agent and exports both classes:

// src/index.ts
import { routeAgentRequest } from "agents";

export { Sandbox } from "@cloudflare/sandbox";
export { Dot } from "./dot";

export default {
  async fetch(request: Request, env: Env) {
    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  },
} satisfies ExportedHandler<Env>;

The model sits behind one function, so a different provider is a one-line change:

// src/model.ts
import type { LanguageModel } from "ai";
import { createWorkersAI } from "workers-ai-provider";

// One place decides which model the dot thinks with. Swap the provider here
// and nothing else changes.
export function brain(env: Env): LanguageModel {
  const workersai = createWorkersAI({ binding: env.AI });
  return workersai("@cf/openai/gpt-oss-120b");
}

The model matters more here than in a chat app, because a dot lives on tool calls. Pick one that’s reliable at them: on Workers AI, gpt-oss-120b handles this dot’s tools well, while Llama 4 Scout tends to invent tool names that don’t exist. Check your choice on your own tasks before you trust it with a schedule.

1. Memory that outlives the conversation

A dot gets better because it remembers what you told it last week. In the Agents SDK every agent has synchronised state and its own SQLite database, both persisted automatically. State suits small things the owner’s apps want to see straight away; SQL suits history:

// src/dot.ts (excerpt)
type State = { preferences: string[] };

export class Dot extends Agent<Env, State> {
  initialState: State = { preferences: [] };

  async onStart() {
    this.sql`CREATE TABLE IF NOT EXISTS held (
      id TEXT PRIMARY KEY, tool TEXT NOT NULL, input TEXT NOT NULL, at INTEGER NOT NULL)`;
    this.sql`CREATE TABLE IF NOT EXISTS journal (
      at INTEGER NOT NULL, task TEXT NOT NULL, outcome TEXT NOT NULL)`;

    // Weekdays at 07:00 UTC: look around before the owner is awake.
    await this.schedule("0 7 * * 1-5", "morningSweep");
  }

  // Memory: feedback becomes a standing instruction.
  @callable()
  async remember(preference: string) {
    this.setState({ preferences: [...this.state.preferences, preference] });
  }

Every preference is fed into the dot’s instructions on every task, so “don’t book anything before 10” stops being something you repeat. Each finished task is written to the journal table, which gives you an audit trail of what it did and why. That’s the record you’ll want the first time it does something surprising.

2. A computer of its own

OpenAI’s example dot pulled the details from an email thread and drafted an invoice. Work like that is easier with a real machine: a place to run a script, parse a spreadsheet or render a PDF. The Sandbox SDK gives each dot a Linux container of its own, addressed by name, that sleeps when idle and wakes on the next command:

// src/computer.ts
import { getSandbox } from "@cloudflare/sandbox";
import { tool } from "ai";
import { z } from "zod";

// The dot's own computer: a Linux container that belongs to this dot alone.
export function computerTools(env: Env, dotName: string) {
  const sandbox = getSandbox(env.Sandbox, `dot-${dotName}`);

  return {
    run: tool({
      description:
        "Run a shell script on your own Linux computer. Python 3 and Node are installed. Files in /workspace persist between runs.",
      inputSchema: z.object({ script: z.string() }),
      execute: async ({ script }) => {
        const proc = await sandbox.exec(["/bin/bash", "-lc", script], {
          cwd: "/workspace",
          timeout: 120_000,
        });
        const out = await proc.output({ encoding: "utf8" });
        return {
          exitCode: out.exitCode,
          stdout: out.stdout.slice(-4000),
          stderr: out.stderr.slice(-2000),
        };
      },
    }),
  };
}

Two details matter. exec takes an argument list and returns a process handle, not a result, so the shell is explicit and the output is collected separately. And the output is trimmed before it goes back to the model, because a script that prints a megabyte of logs would otherwise fill the model’s context with noise. The container runs code the model wrote, which is the point of isolating it: it has no access to your accounts or your Worker’s secrets unless you pass them in.

3. Work that starts without a prompt

The difference between a chatbot and a dot is who starts the conversation. The schedule call in onStart registers a cron job that survives restarts. Calling it again on every wake doesn’t create duplicates, because cron schedules are deduplicated by default. When it fires, the agent calls morningSweep, which hands a standing task to the same loop the owner uses:

// src/dot.ts (excerpt)
  // Proactive work, triggered by the schedule rather than a prompt.
  async morningSweep() {
    await this.work(
      "Check my inbox and calendar. Draft replies to anything waiting on me, " +
        "flag anything due this week, and leave me a short summary."
    );
  }

  @callable()
  async work(task: string) {
    // After a wake-up, connected apps reconnect in the background.
    await this.mcp.waitForConnections({ timeout: 10_000 });
    const tools = gate(
      { ...computerTools(this.env, this.name), ...this.mcp.getAITools() },
      (tool, input) => this.hold(tool, input)
    );

    const { text } = await generateText({
      model: brain(this.env),
      system: [
        "You are a personal agent working on your owner's behalf.",
        "Never claim an action is done if a tool says it is awaiting approval.",
        ...this.state.preferences.map((p) => `Owner preference: ${p}`),
      ].join("\n"),
      prompt: task,
      tools,
      stopWhen: stepCountIs(25),
    });

    this.sql`INSERT INTO journal VALUES (${Date.now()}, ${task}, ${text})`;
    return text;
  }

The stepCountIs(25) cap is a backstop, not a strategy. A loop that hits it has usually gone in circles, which is where a fast decision model checking “is this agent done, and is it stuck?” between steps earns its keep; we built that check for an agent loop, and OpenAI’s new Decisions API offers the same kind of call.

4. Your apps, behind an approval step

A dot is only as useful as what it can reach. The Agents SDK connects to any remote MCP server, including the ones that mail, calendar and CRM providers publish, and handles the OAuth sign-in. Their tools arrive in the same format as the dot’s own:

// src/dot.ts (excerpt)
  // Connected apps: any remote MCP server, such as mail, calendar or a CRM.
  @callable()
  async connectApp(name: string, url: string) {
    const result = await this.addMcpServer(name, url);
    return "authUrl" in result ? { authorise: result.authUrl } : { connected: true };
  }

Two details of connected tools matter. They arrive with a prefix, so a mail server’s send tool appears as something like tool_<id>_send_email, which is why the guardrail matches anywhere in the name. And when the dot wakes, from its schedule or after sleeping, those connections are restored in the background, so work and approve wait for them before listing tools. Without that wait, a dot woken by its own cron finds it has no apps.

This is also where a dot becomes dangerous. OpenAI held back GPT-6.1 Astra because it pushed ahead with tasks without asking and misreported what it had done. The defence here doesn’t depend on any model being trustworthy. Every tool whose name says it sends, deletes, pays or publishes is swapped for one that parks the request:

// src/guardrail.ts
import type { ToolSet } from "ai";

// Anything that sends, deletes, pays or publishes waits for a person.
const IRREVERSIBLE = /send|delete|remove|pay|transfer|publish|post|invite/i;

export type Hold = (tool: string, input: unknown) => Promise<string>;

export function gate(tools: ToolSet, hold: Hold): ToolSet {
  return Object.fromEntries(
    Object.entries(tools).map(([name, t]) => [
      name,
      IRREVERSIBLE.test(name)
        ? {
            ...t,
            execute: async (input: unknown) => ({
              status: "awaiting_approval",
              id: await hold(name, input),
              note: "Not done yet. The owner has been asked to approve this.",
            }),
          }
        : t,
    ])
  );
}

The parked action is stored, the owner is told, and only a person can let it through:

// src/dot.ts (excerpt)
  // Guardrail, part one: park the action and tell the owner.
  private async hold(tool: string, input: unknown) {
    const id = crypto.randomUUID();
    this.sql`INSERT INTO held VALUES (${id}, ${tool}, ${JSON.stringify(input)}, ${Date.now()})`;
    this.broadcast(JSON.stringify({ type: "approval_needed", id, tool, input }));
    return id;
  }

  // Guardrail, part two: only a person can let it through.
  @callable()
  async approve(id: string) {
    const [row] = this.sql<{ tool: string; input: string }>`
      SELECT tool, input FROM held WHERE id = ${id}`;
    if (!row) throw new Error("Nothing waiting with that id.");

    await this.mcp.waitForConnections({ timeout: 10_000 });
    const tool = this.mcp.getAITools()[row.tool];
    if (!tool?.execute) throw new Error(`${row.tool} is no longer connected.`);

    const result = await tool.execute(JSON.parse(row.input), {
      toolCallId: id,
      messages: [],
    });
    this.sql`DELETE FROM held WHERE id = ${id}`;
    return result;
  }

  @callable()
  async decline(id: string) {
    this.sql`DELETE FROM held WHERE id = ${id}`;
  }
}

The model sees “awaiting approval” and carries on with what it can do, the instruction in its system prompt stops it from reporting the email as sent, and the approval happens hours later from a phone if need be. broadcast only reaches apps that are connected at that moment, so a production dot also sends the request somewhere the owner will see it; the Agents SDK can send email and web push notifications. And the name filter is deliberately blunt. It will hold some harmless calls, which costs a tap. Letting through one it shouldn’t costs far more.

What we’d hand a dot

The jobs that suit this design are recurring, low-stakes to draft and high-stakes to send. Triaging an inbox before the day starts and drafting the replies. Chasing invoices: finding the ones overdue, drafting the reminders, waiting for a yes. Watching a competitor’s pricing page or a supplier’s stock and summarising what changed. A weekly research digest on a topic, written by a dot that has learned which sources you trust.

What we wouldn’t hand it, unsupervised, is anything where a mistake leaves the building: payments, contracts, messages to customers, deletions. Not because the models are bad at those tasks, but because the cost of being wrong once outweighs every hour the automation saves. The approval step keeps the dot useful for those jobs too. It just means a person says yes.

The examples run as shown with agents 0.26, the Sandbox SDK 1.0 preview (@cloudflare/sandbox@next), AI SDK 7 and workers-ai-provider 4. The Sandbox SDK preview is still moving, so check its documentation before you build on it.


Sources

Got an idea?Tell us about it.

Send a short brief. We'll come back with questions, a call slot and a straight answer on whether we're the right team.

Start a project