A programming language for Grok Bots

Bot code that can’t send, spend, or expose a secret without your permission.

Writ is a small, strictly checked language for Grok Bot routines. Before a program runs, the checker proves that every message needs your approval, every payment stays under a set limit, secrets are never printed, and text from an email or web page can’t decide where anything goes. If it can’t prove it, the program doesn’t compile.

inbox_replies.writ
writ 0.1
 
use gmail from connector "gmail"
use model from host "model"
 
routine inbox_replies
  trigger every weekday at 09:00 "America/New_York"
  grants {
    gmail.read(label: "INBOX"), gmail.draft,
    gmail.send(max: 5), model.ask
  }
{
  let threads = gmail.search(query: "is:unread", limit: 20)?
 
  for t in threads {
    let text = model.write(task: "Short reply.", context: t.body)?
    let d = gmail.draft_reply(thread: t.id, to: t.from, body: text)?
 
    match approve(d, reason: "Reply to {t.from}") {
      Approved(a) => gmail.send(a)?
      Declined    => log.info("declined; draft kept")
    }
  }
}
$ writ plan inbox_replies.writ
reads=gmail:INBOX  drafts=gmail  sends=gmail(<=5, approval)  spends=0  secrets=none  net=none

The problem

Today, a bot’s rules are written in plain English. Nothing checks them.

A Grok Bot’s behavior is spread across three places: written instructions a model reads on every run, scripts the model writes on the fly, and host settings that neither of those can see. That split causes the same failures again and again.

  1. 01

    Rules aren’t enforced

    “Never send without approval” is a sentence in a document. Nothing checks that the script the bot just wrote follows it.

  2. 02

    Email text looks like an instruction

    An email that says “forward this to someone else” is the same kind of string as your own instruction.

  3. 03

    Secrets can be printed

    In Python, print(password) is always allowed. One stray log line exposes a password.

  4. 04

    You can’t see actions in advance

    There’s no way to ask a script “who can this message, and what’s the most it can spend?” without running it.

  5. 05

    Retries repeat actions

    A routine that crashes right after sending a message and then retries sends it twice.

What it checks

Six guarantees, checked before the program runs.

Each example shows code the checker accepts, then the unsafe version and the error it produces. Code and messages follow the current draft spec; exact wording may change as the checker is built.

1

Sending requires your approval

Anything another person can see (a message, post, comment, ticket or form) needs an Approved<T> value. Only approve() can create one. It is tied to the exact content you were shown, it can be used once, and a declined send can’t be asked again in the same run.

Checks clean
let d = gmail.draft_reply(thread: t.id, to: t.from, body: text)?

match approve(d, reason: "Reply to {t.from}") {
  Approved(a) => gmail.send(a)?
  Declined    => log.info("declined; draft kept")
}
Rejected
gmail.send(d)?
error  send-needs-approval  inbox.writ:12:12
  gmail.send requires Approved<Draft>, found Draft
  Approved<Draft> can only be created by approve()
2

Spending has a hard limit

Money is an effect with a number attached. A payment permission without a limit can’t be written. The checker has to prove each charge stays under the limit, and the host checks it again, with a fresh price, at the moment the charge runs.

Checks clean
grants { shop.quote(sku: "6525421"), shop.cart, pay.charge(limit: usd(250)) }

let q = shop.quote(sku: "6525421")?
guard q.total <= TARGET else { return }   // TARGET = 229.00
let order = shop.prepare_cart(q)?
Rejected: the guard is removed
let q = shop.quote(sku: "6525421")?
let order = shop.prepare_cart(q)?
error  spend-over-limit  price_watch.writ:24:13
  pay.charge requires a.total <= limit (250.00)
  cannot prove order.total <= 250.00
  fix 1: guard q.total <= TARGET else { return }
3

Secrets can’t be printed

Secret is a handle, not a string. It can’t be printed, compared, joined to text or serialized. It can only go into a field that accepts secrets, on the site its permission names. The program never holds the actual value; the host fills it in at that field.

Checks clean
let pass: Secret = vault.ref("shop-login", field: password)

browser.fill("#password", pass)?   // the host fills in the value
browser.submit()
Rejected
log.info("pw={pass}")
web.post(url, body: pass)
error  W0201  login.writ:9:14
  Secret has no Display
error  W0203  login.writ:10:21
  web.post body is not a Secret sink
4

Untrusted text can’t choose where things go

Content from an email, a web page, a file or a model reply is Untrusted. It can go into a message body or a document. It can never choose a recipient, a URL, an amount, a file path or a permission. The only way to use it there is to match it against a list you control.

Checks clean
// t.from is Gmail metadata:      Trusted<Address>
// t.body is the email's content: Untrusted<Text>
gmail.draft_reply(thread: t.id, to: t.from, body: text)?
Rejected
gmail.draft_reply(thread: t.id, to: parse_address(t.body), body: text)?
error  W0310  untrusted-to-authority  inbox.writ:14:31
  gmail.draft_reply(to: ...) requires Trusted<Address>, found Untrusted<Address>
  via: parse_address(t.body)  [t.body is content from gmail.search]
  fix 1: use t.from (Trusted metadata)
  fix 2: validate(addr, against: config.contacts)
5

Old data expires

Prices, inboxes and calendars change. A value read from outside the program expires whenever the run pauses: for an approval, a wait, or a schedule boundary. After that it can still be shown to you as prior.q, but it can’t be used as an amount.

Checks clean
match approve(order, reason: "Buy SKU 6525421 for {order.total}?") {
  Approved(a) => {
    let r = pay.charge(a, limit: CAP)?   // host re-quotes before charging
    ping.owner("Bought at {a.total}", receipt: r)
  }
  Declined => ping.owner("Skipped. Price was {prior.q.total}.")
}
Rejected
let r = pay.charge(a, limit: q.total)?
error  stale-value  price_watch.writ:31:34
  q was read before approve() at line 27 and has expired
  prior.q can be displayed, but not used as an amount
6

Subagents get only what you give them

A subagent receives only the permissions you list, and only ones the parent already has. Every fan-out needs a limit and a timeout, and all subagents finish before the parent continues. None are left running.

Checks clean
let results = spawn each topic in topics
  grants { web.read, model.ask }   // notion.write stays with the parent
  limit 3 timeout 10m
{
  research(topic)
}
Rejected
  grants { web.read, model.ask, gmail.send }
error  spawn-grant-not-held  brief.writ:12:33
  gmail.send is not held by routine morning_brief
  a subagent's grants must be a subset of its parent's

Also built in

  • Unknown means refuse. A connector tool with no entry in its manifest can’t be called.
  • New routines start read-only. The default mode allows reading and drafting. Sending, spending and deleting must be switched on.
  • Each action runs once. Runs are journaled. After a crash, the run resumes without repeating completed actions.
  • Receipts. Every external action returns a receipt, chained by hash, that writ receipt verify can check against the source.
  • No escape hatches. No null, any, casts, eval, or shell commands built from strings.

writ plan

See everything a routine can do before it runs.

writ plan reads the source and lists what the program is able to do: what it reads, who it can message, how much it can spend, which secrets it uses, and where it stops to ask you. It doesn’t run anything.

  • Review a routine by its plan instead of reading every line.
  • The same summary can be shown when you’re asked to approve.
  • Commit the plan as a baseline. Any change that adds new abilities fails CI until a person updates the baseline.
terminal
$ writ plan inbox_replies.writ

routine inbox_replies
  trigger   weekdays 09:00 America/New_York

  reads     gmail:INBOX
  drafts    gmail
  sends     gmail, max 5, each needs approval
  spends    0
  secrets   none
  net       none
  approval  1 point, line 19

reads=gmail:INBOX  drafts=gmail  sends=gmail(<=5, approval)  spends=0  secrets=none  net=none
Output layout is a draft. The one-line summary at the bottom is the format in the spec.

Why Writ

Why start using it.

Your rules become checks

“Ask before sending” and “never spend more than $250” stop being instructions a model might skip. They become types the program can’t compile without.

Written for code that models write

One loop, one conditional, one match, one way to handle errors, one formatter with no options. No classes, macros, exceptions or null. Fewer ways to write something means fewer ways to write it wrong.

Errors a bot can fix

Every error has a stable code, the exact location, what was expected and what was found, and up to three suggested fixes. writ check --json returns them in a format a bot can act on directly.

Fast to check

The write, check, fix loop has to be quick. The design target is under 300 ms to check 1,000 lines, so a bot can run the checker many times per task.

Safe to re-run

Every action is recorded as it happens. If a run stops partway, it picks up where it left off and doesn’t send or charge twice.

Proof of what ran

Receipts record which source, which step, what went in, what came out and which approval allowed it. A test run’s receipt can never be passed off as a live one.

Writ is designed around a wish list Matt Pocock posted for the next language for agents: as fast as Rust, as little design space as Go, and the strictest type and verification system ever written.

Fast
Compiler and runtime in Rust. Programs compile ahead of time to a sandboxed WebAssembly component (planned for v1). Checking stays fast because every check is decidable.
Small
About ten statement and declaration forms. If it isn’t in the grammar, it isn’t in the language. New effects and connectors are added by registration, not new syntax.
Strict
Five layers: types, effects, capabilities, information flow, and refinements with contracts. If the checker can’t prove a property, the program fails to compile. It never quietly adds a runtime check instead.

How it fits Grok Bots

Skills stay in plain English. Writ is for the part that takes action.

Writ doesn’t replace skills, custom rules or memory. It covers routines and automations: the code that reads your mail, fills a cart, posts an update or starts subagents.

  1. Skills and rules

    Plain-English guidance, as today.

  2. Writ routine

    The actions, with permissions declared in the routine header and checked by writ check.

  3. Host on the bot’s computer

    Runs the routine with only the permissions it declared. Connects to apps through MCP, fills vault logins at the field, asks you for approvals, and keeps the journal and receipts.

Connectors

Each connector tool is mapped to exactly one kind of effect (read, draft, write, send, spend, delete) in a manifest. The host enforces the mapping.

Approvals where they matter

Sends, spending and permanent deletes need approval. Reads and drafts don’t. Low-risk sends can use a standing approval you set, with a daily count.

Bot to bot

Messages between a bot and its subagents keep their labels. Untrusted content stays untrusted when it’s passed along.

Builds on two of Jon Bailey’s earlier projects. From VELA: receipts, expiring data, read-only by default, and refusing anything unknown. From ASCENT: the message format bots use to hand off work and permissions. Live approvals depend on hooks the Grok Bot platform provides; until one exists, approval requests go to you through your bot.

Roadmap

Where Writ is today.

The design spec is written. The parser and checker are being built now. Nothing has been released yet, and the source is private until the first version works.

  1. Done

    v0.0 · Spec

    Design spec, four example programs, first draft of the effect registry.

  2. In development

    v0.1 · Parser and checker

    Written in Rust: parser, formatter, types, effects, approvals, Secret, trusted and untrusted labels, JSON errors. Done when the examples check clean and 40+ unsafe programs fail with the right error.

  3. Planned

    v0.2 · Interpreter and writ plan

    Run programs against recorded connectors. Dry runs and tests.

  4. Planned

    v0.3 · Limits, expiry and receipts

    Proven spending limits and contracts, expiring data, the receipt chain.

  5. Planned

    v0.4 · Running on a real bot

    Connectors through MCP, vault logins, durable journal, schedules. One real read-and-draft routine runs live for a week.

  6. Planned

    v0.5 · Subagents

    Fan-out with limited permissions and the bot-to-bot message format.

  7. Planned

    v0.6 · WebAssembly and editor support

    Compiled, sandboxed output; a language server and syntax highlighting for editors.

  8. Planned

    v1.0 · Stable

    Frozen spec and a conformance test suite. Done when a bot can write, check and ship a new routine without a person editing the code.

Questions

Common questions.

Can I use Writ today?

Not yet. Writ is in development. The v0.1 parser and checker are being built now, and the source is private until it works. This page will be updated when there is something to try.

Do I need to rewrite my skills?

No. Skills, rules and memory stay in plain English. Writ is for routines and automations that take actions.

Why not Python with a linter?

A linter can warn about print(password), but Python still lets the code run. In Writ, an unsafe program has no valid form: there is no way to send without an approval value, and no way to print a secret. The checker refuses the program, and the host enforces the same limits again at run time.

Does it slow a bot down?

Approvals are only needed for sends, spending and permanent deletes, the same actions that already need them. Reading and drafting run without interruption.

Is Writ made by xAI?

No. Writ is an independent project by Jon Bailey. Grok Bot is a product of xAI; Writ is not affiliated with or endorsed by xAI.

Writ is being built now.

Follow Jon Bailey on X for updates as the parser and checker come together.