Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Redstart is a multi-file language for authoring The Graph subgraphs.

A subgraph today is three loosely-coupled artifacts — schema.graphql, subgraph.yaml, and AssemblyScript mappings — stitched together by stringly-typed names and a manual graph codegen step. Drift between them is the dominant source of “it compiled but failed at runtime, three hours into a sync.”

Redstart unifies all three into one language, split across as many .red modules as you like (mod/use, just like Rust). It type-checks them against each other and transpiles to readable AssemblyScript that the canonical graph build toolchain compiles unmodified.

abi ERC20 from "./abis/ERC20.json"

entity Account {
  id: Id<Bytes>
  balance: BigInt
  label: Option<String>          // nullability is always explicit — there is no `null`
}

source Token {
  abi: ERC20
  network: mainnet
  address: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
  startBlock: 6082465
}

handler on Token.Transfer(event) {
  let receiver = Account.loadOrCreate(event.params.to, { balance: BigInt.zero })
  receiver.balance = receiver.balance + event.params.value
  // auto-saved at handler end — forgetting `.save()` can't happen
}

redstart build turns that into schema.graphql + subgraph.yaml + mappings.ts. The event signature in the manifest is derived from the ABI by reference — rename the event and it’s a compile error, not a runtime one.

Try it now — the playground runs the real compiler in your browser: write .red, watch the generated AssemblyScript, schema, and manifest update as you type.

Why a language?

The killer feature is unification, not syntax. A single source of truth makes manifest/schema/handler drift impossible. The whole class of AssemblyScript footguns — nullable-arithmetic miscompiles, ==/=== inversion, reverted-call aborts, array prefill, forgotten .save() — becomes unrepresentable by construction.

The eject path (see How it works) means abandoning Redstart costs nothing but the generated code, which keeps working. Redstart does not make indexing faster; it makes staying on The Graph’s decentralized network pleasant. It is scoped as a Graph-Foundation-grant public good in the lineage of Matchstick, not a venture bet.

Installation

Redstart ships pre-built binaries for macOS (arm64/x86_64) and Linux (x86_64/arm64) — no Rust toolchain required.

Quick install

curl -fsSL https://raw.githubusercontent.com/nightswatchhq/redstart/main/scripts/install.sh | sh

This downloads the binary for your platform from the latest release, verifies its sha256, and installs it to ~/.local/bin (override with REDSTART_BIN_DIR).

Homebrew

brew install nightswatchhq/tap/redstart

Cargo

If you’d rather build from source (needs a recent stable Rust):

cargo install --git https://github.com/nightswatchhq/redstart redstart-cli

Verify

redstart --version

What you’ll also want

To build and deploy a subgraph, the canonical Graph toolchain (graph-cli) is invoked through redstart deploy; it runs via npx, so you only need Node 18+ on your PATH. Redstart never reimplements graph build — it generates the input to it.

Quick start

A new project

redstart new my-subgraph
cd my-subgraph

This scaffolds a redstart.toml manifest and a src/ directory with a starter module.

The project layout

my-subgraph/
├── redstart.toml        # project name, description, output dir
└── src/
    ├── main.red         # the root module (sources, handlers)
    └── abis/            # contract ABIs you import

A .red file can declare entities, ABIs, sources, templates, handlers, free functions, and tests. Split them across modules with mod name; and refer across modules with name::Thing — exactly like Rust.

The workflow

redstart check      # type-check the whole project, across every module
redstart build      # emit schema.graphql + subgraph.yaml + src/mappings.ts
redstart test       # run native tests (no WASM, no Docker)
redstart fmt        # canonical formatting (--check to verify in CI)
redstart dev        # watch loop: check → build → test on every save
redstart deploy <slug>   # build → graph codegen → graph build → graph deploy

Try the examples

The repository ships four worked examples, including a faithful port of a real-world subgraph:

redstart test examples/erc20
redstart build examples/horizon-indexer

Read on for a tour of the language itself, starting with entities.

Porting an existing subgraph

Three handwritten artifacts become one Redstart project. schema.graphql becomes entity declarations, subgraph.yaml becomes source and template blocks, and the AssemblyScript mappings become handler bodies. What follows is the order to do it in, the translation tables, and the places where a mechanical translation will not work.

Do it in this order. Schema first, because everything else references it; then sources; then one handler at a time, running redstart check after each. Leave the receipt-dependent handlers for last. They need redesigning rather than translating, and they are covered at the end.

Finish with verify, not build

redstart check     # is the Redstart valid?
redstart build     # emit schema.graphql + subgraph.yaml + mappings.ts
redstart verify    # …and prove the generated AssemblyScript compiles to WASM

check and build answer questions about your source. Only verify runs the canonical graph codegen + graph build over the output, and that is the question which decides whether a deploy will work. Run it before every deploy and in CI. It needs Node and npm; the toolchain is installed into the build directory the first time. (redstart deploy <name> --dry-run does the same thing and then stops.)

The generated directory is output, not source. Add it to .gitignore (redstart new does this for you) and never edit it, since the next build overwrites it.

Schema: types

schema.graphqlRedstart
id: ID!id: Id<Bytes> (preferred) or id: Id<String>
field: BigInt!field: BigInt
field: BigInt (nullable)field: Option<BigInt>
field: Boolean!field: Boolean (or Bool, both are accepted)
field: Int! / Int8!field: Int / Int8
field: [String!]!field: [String]
field: Token!field: Token
field: [Trove!]! @derivedFrom(field: "owner")field: [Trove] derived from owner
@entity(immutable: true)entity X immutable { … }
type X @entity { … } implements Yentity X implements Y { … }

Everything is non-null unless you write Option<T>; there is no null in the language. A derived from field is read-only, and assigning to one is a compile error rather than a silent no-op.

Keep your existing id formats if a frontend queries them. Redstart will warn (W040) that a stringified address would be cheaper as Id<Bytes>, and it is right, but changing an id changes entity identity and breaks existing queries. Accepting that warning on compatibility-sensitive entities is a legitimate choice; redstart explain W040 spells out the trade.

Mappings: idioms

AssemblyScriptRedstart
let e = Entity.load(id); if (e == null) { e = new Entity(id); … }let e = Entity.loadOrCreate(id, { … })
new Entity(id)Entity.create(id, { … })
entity.save()nothing at all: entities auto-save (E055 if you write it)
a.plus(b) / a.minus(b) / a.times(b) / a.div(b)a + b / a - b / a * b / a / b
let r = c.try_f(x); if (!r.reverted) { r.value … }match C.bind(addr).f(x) { Ok(v) => { … } Err(e) => { … } }
let e = Entity.load(id); if (e != null) { e.field … }match Entity.load(id) { Some(e) => { … } None => { … } }
entity.field = nullentity.field = None (the field must be Option<T>)
x ? a : blet v = b then if cond { v = a }, there is no ternary
const ZERO = BigInt.fromI32(0)a helper: fn zero() -> BigInt { return BigInt.zero }
Template.create(addr)Template.create(addr) (unchanged)

load returns Option<T> and a contract call returns Result<T, _>; both must be matched before use, which is what makes the null-deref and the reverted-call abort unrepresentable. Control flow (if / while / for) and free fn helpers lower to the obvious AssemblyScript.

What a handler can see

An event handler’s event binding carries exactly this:

Available
event.params.<name>the decoded parameters, named as the ABI names them (unnamed inputs are param0, param1, …). A name the ABI doesn’t declare is E057.
event.addressthe contract that emitted the event
event.ida unique id derived from the transaction hash and log index
event.logIndex, event.transactionLogIndexposition within the block / transaction
event.block.number, .timestamp, .hashthe block
event.transaction.hash, .from, .to, .value, .gasPricethe transaction
dataSource.address(), dataSource.context(), dataSource.network()the data source, including template context

Not available: event.receipt. Redstart does not expose the transaction receipt, so a handler cannot read neighbouring logs, scan the transaction for another contract’s topic, or decode a log at a fixed offset. If the subgraph you’re porting does any of that, it needs the redesign below.

Call handlers see call.inputs.<name>, call.outputs.<name>, call.block and call.transaction. Note that call handlers need Parity-style tracing, which most L2s do not provide, W010 warns when the network can’t support them.

Replacing receipt inspection

The pattern that replaces it: handle each event directly, and correlate them through an entity keyed by transaction hash.

Concretely, where the old mapping read a future log from inside a handler:

  1. the first handler writes what it knows into a staging entity whose id contains event.transaction.hash (plus any discriminator, a collateral index, a borrower, a batch manager);
  2. the handler for the event that used to be read out of the receipt runs normally when graph-node reaches it, loads the staging entity, and completes the work.

This is strictly more robust than a logIndex + 2 assumption, which breaks the moment the contract emits an extra log. It does rely on the two events landing in the expected order within the transaction, so write that invariant down and test it, a contract change can violate it.

Two things to decide deliberately. Staging entities are implementation detail rather than public API, so name them accordingly (PendingBatchUpdate, PendingLiquidation) and document that consumers should ignore them. And if more than one occurrence per transaction is possible, key or accumulate accordingly - a single pending record silently overwrites.

Reading other contracts

Declare the ABI and bind it. Nothing else is needed: the ABI is added to the manifest and its contract class imported wherever it’s bound, including from inside a helper fn.

abi CollateralRegistry from "./abis/CollateralRegistry.json"

fn tokenFor(registry: Address, index: BigInt) -> Bytes {
  match CollateralRegistry.bind(registry).getToken(index) {
    Ok(token) => { return token }
    Err(e) => { return Bytes.empty }
  }
}

bind takes an Address, so a helper that binds should declare its parameter Address rather than Bytes (event.address and dataSource.address() are already Address).

Earlier versions needed an inert template with a dummy block handler to make the contract class appear. That is no longer necessary; delete those declarations.

Contract calls are the main sync-speed lever: each one is a blocking RPC round trip. W020 flags a call inside a loop, which is the classic “stuck at 3%” shape. Hoist it or cache the result.

Several sources, one event name

Perfectly fine. Three registry contracts all handling CollSurplusPoolAddressChanged generate three distinct exported functions - handleAddressesRegistrySource0CollSurplusPoolAddressChanged and so on, with the manifest pointing each source at its own. Handler symbols stay unqualified (handleTransfer) when there is nothing to disambiguate.

Multi-network deployment

The manifest carries one network, taken from each source block. To deploy the same subgraph to several networks, keep the addresses and start blocks in your own configuration and rewrite the source blocks before building, or generate per-network project directories. graph build --network and networks.json still work on the generated output, since it is an ordinary subgraph.

Start blocks matter more than they look. Prefer the block of the initialisation event you depend on: a once block handler that calls a contract can run before that contract has been initialised, and on a local chain it can run before the address exists at all. Handling the address-change event directly works on both historical and fresh deployments.

Things that will not translate

  • Receipts. Covered above.
  • Dynamic array sizing from context. Arrays are written as literals ([0, 0, 0]), so a length that depends on runtime context has to be handled explicitly, build the array in a loop, or accept the fixed length and assert the invariant that justifies it.
  • A ternary expression. Use an if.
  • Module-level constants. Use a helper fn.
  • Fulltext search, grafting, and manifest features. Not yet emitted.

When you find a gap

redstart check catching a mistake is the good case, and redstart verify failing is the acceptable one. A generated mapping that fails graph build without either of them complaining is a bug in Redstart, please open an issue with the smallest .red file that reproduces it.

Entities & the schema

Entities are the heart of a subgraph — they define what gets stored and queried. In Redstart they’re declared once and projected into schema.graphql automatically.

entity Account {
  id: Id<Bytes>
  balance: BigInt
  label: Option<String>          // nullable — see the nullability chapter
  transfersOut: [Transfer] derived from from
}
  • id: Id<Bytes> marks the primary key. Id<Bytes> and Id<String> are the two forms graph-node supports.
  • [Transfer] derived from from is a derived (virtual) field: it’s computed from the from field on Transfer, never written directly. Assigning to it is a compile error.

Immutable entities

entity Transfer immutable {
  id: Id<Bytes>
  from: Account
  to: Account
  value: BigInt
  timestamp: BigInt
}

Immutable entities can never be updated after creation, so graph-node stores them far more cheaply. The modifier flows straight into the @entity(immutable: true) directive in the generated schema.

Bytes ids vs String ids

Id<Bytes> and Id<String> are the two id forms graph-node supports, but they are not equal: a Bytes id indexes ~28% faster and stores ~48% less than the same value kept as a hex string (Edge & Node benchmark). So when an entity is keyed on a single address or bytes value, key it on the raw value — not on value.toHexString().

The checker flags the stringified form with W040, and there’s an opt-in autofix:

$ redstart fix --ids            # or --dry-run to preview
  ✓ Holder  (2 sites) → Id<Bytes>
  ⤫ Ledger  skipped: keyed on a literal string id (src/ledger.red:12)

It flips the entity’s declaration to Id<Bytes> and drops the .toHexString() at every construction site, in one pass — including the common let id = addr.toHexString(); E.create(id, …) shape (when id is used only there). It is deliberately conservative: an entity is only converted when every one of its id sites is a single stringified address/bytes value — one literal-string or composite (a + "-" + b) id and the whole entity is left untouched and reported. Genuine composite keys are really strings and stay Id<String>.

Because a Bytes id changes the stored id representation (hex-string → raw bytes), this is a real data change — redeploy affected subgraphs from the relevant block.

One-to-many relations: derived from, not stored arrays

To model “a Pool has many Accounts”, reach for a derived relation — never a stored array of entity references:

entity Account {
  id: Id<Bytes>
  pool: Pool                       // the back-reference
}

entity Pool {
  id: Id<Bytes>
  accounts: [Account] derived from pool   // computed, never stored
}

A stored [Account] (an entity array without derived from) is kept inline by graph-node, which rewrites the entire array into a new versioned row on every append — O(n²) disk as the relation grows. A derived from field is a reverse lookup computed on read, so appends stay O(1). The checker flags the stored form with W050:

$ redstart check
  ! `accounts` stores an array of `Account` entities
  help: model this one-to-many with `@derivedFrom`: add a back-ref field on
        `Account` (e.g. `pool: Pool`) and declare `accounts: [Account] derived from pool`

Scalar and enum arrays ([String], [BigInt], [TokenStandard]) are genuinely stored values and are never flagged — only arrays of entities are.

Enums, interfaces, and scalars

enum TokenStandard { ERC20, ERC721, ERC1155 }

interface Token {
  id: Id<Bytes>
  symbol: String
}

entity FungibleToken implements Token {
  id: Id<Bytes>
  symbol: String
  decimals: Int8
}

implements is checked for field completeness — leave out a field the interface requires and it won’t compile. The full graph-node scalar set is available, including Int8, Timestamp, BigInt, BigDecimal, Bytes, and Boolean.

Timeseries & aggregations

entity Swap timeseries {
  price: BigDecimal
}

aggregation PriceStats over Swap every [hour, day] {
  total: BigDecimal = sum(price)
}

Timeseries entities get an automatic id/timestamp and are implicitly immutable. Aggregations render to @aggregation/@aggregate and automatically bump the manifest specVersion to 1.1.0.

Sources & ABIs

A source is an on-chain contract you index. An ABI import gives Redstart the contract’s interface, which it uses to type events and contract calls — and to derive the event signatures written into the manifest.

abi ERC20 from "./abis/ERC20.json"

source Token {
  abi: ERC20
  network: mainnet
  address: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
  startBlock: 6082465
}

Because the event signature in the generated subgraph.yaml (Transfer(indexed address,indexed address,uint256)) is derived from the ABI by reference, renaming or mistyping an event is a compile error rather than a silent runtime mismatch.

Templates (dynamic data sources)

When a factory contract spawns new contracts at runtime, declare a template and instantiate it from a handler:

template Pair {
  abi: UniswapV2Pair
  network: mainnet
}

handler on Factory.PairCreated(event) {
  Pair.create(event.params.pair)
  // or with context:
  // Pair.createWithContext(event.params.pair, ctx)
}

File data sources (IPFS / off-chain metadata)

template TokenMetadata { kind: file }

handler file TokenMetadata(content) {
  // `content` is the fetched file bytes
}

This renders to a kind: file/ipfs data source — the standard off-chain-metadata pattern.

Handlers

Handlers are where indexing logic lives. Redstart has three kinds, each mapping to the corresponding manifest section.

Event handlers

handler on Token.Transfer(event) {
  let sender = Account.loadOrCreate(event.params.from, { balance: BigInt.zero })
  let receiver = Account.loadOrCreate(event.params.to, { balance: BigInt.zero })

  sender.balance = sender.balance - event.params.value
  receiver.balance = receiver.balance + event.params.value
  // Both entities are dirty-tracked and auto-saved at handler end.
}

event.params is typed from the ABI. event.block, event.transaction, and event.address are available as usual.

Auto-save, by construction

You never call .save(). Entities you load or create are dirty-tracked and flushed when the handler returns (and at return from any helper). Forgetting to save — one of the most common subgraph bugs — is simply not expressible.

Call & block handlers

handler call Token.transfer(call) {
  // call.inputs / call.outputs are ABI-typed
}

handler block Token every 100 {
  // runs every 100 blocks; `once` is also supported
}

These render to callHandlers and blockHandlers (with the polling/once filter) respectively.

Control flow

Handlers and helpers support if/else if/else, while, and for over numeric ranges and lists, plus array literals and indexing — all lowered to native AssemblyScript:

for holder in holders {
  if holder.balance > BigInt.zero {
    activeCount = activeCount + 1
  }
}

Nullability & no null

There is no null in Redstart. Anything that might be absent is Option<T>, and the compiler forces you to handle the empty case before you touch the value.

entity Account {
  id: Id<Bytes>
  label: Option<String>     // nullable in the generated schema
}

label renders as label: String (nullable) in schema.graphql, while a plain balance: BigInt renders as balance: BigInt! (required).

Why this matters

In hand-written AssemblyScript, arithmetic on a nullable value silently miscompiles, and a forgotten null check aborts the handler at runtime. Redstart makes both compile errors:

  • Arithmetic on Option is rejected — you must unwrap first.
  • Dereferencing a nullable host return is rejected. store.get-style loads (Entity.load, loadInBlock) and ipfs.cat return Option<T>; you must match them before use:
match Account.load(id) {
  Some(account) => {
    account.balance = account.balance + amount   // matched binding auto-saves
  }
  None => {
    // nothing to update
  }
}

loadOrCreate exists precisely so the common case doesn’t need a match — it always returns a live entity.

Contract calls & match

Reading state from a contract can revert. In hand-written AssemblyScript an unguarded call aborts the whole handler; the idiomatic fix (try_*) is easy to forget. Redstart makes the safe path the only path: a contract call returns a Result, and you must match it before touching the value.

handler on Token.Approval(event) {
  let result = ERC20.bind(event.address).balanceOf(event.params.owner)
  match result {
    Ok(currentBalance) => {
      let owner = Account.loadOrCreate(event.params.owner, { balance: BigInt.zero })
      owner.balance = currentBalance
    }
    Err(e) => {
      // call reverted — leave balances untouched
    }
  }
}

This lowers to graph-ts’s try_balanceOf() and a .reverted check — the correct pattern, generated for you. Accessing .value without a surrounding match is a compile error (E…: .value-without-match).

Binding

ERC20.bind(address) produces a typed contract instance. Every function in the imported ABI is available, with ABI-typed parameters and return values.

Helpers & modules

Modules

A Redstart project is a tree of modules, exactly like Rust. Declare a child module with mod, and refer across modules with ::.

// src/main.red
mod accounts;

handler on Token.Transfer(event) {
  let receiver = accounts::Account.loadOrCreate(event.params.to, { balance: BigInt.zero })
  // ...
}
// src/accounts.red
entity Account {
  id: Id<Bytes>
  balance: BigInt
}

Entities can live in one module and the handlers that write them in another; the compiler resolves and checks across all of them. mod name; resolves to a sibling name.red or a nested name/mod.red, with cycle detection.

Helper functions

Free fn declarations factor out shared logic. They lower to AssemblyScript functions, work across modules, and return typed values.

fn normalize(amount: BigInt, decimals: i32) -> BigDecimal {
  return amount.toBigDecimal() / exponent(decimals)
}

Entities touched inside a helper are dirty-tracked too: their saves are flushed at every return (and at the end of the calling handler), so the auto-save guarantee holds across function boundaries.

Testing

Redstart has a built-in test runner that executes natively — no WASM, no Docker, no Matchstick. Tests live alongside your code in any .red module and run with redstart test.

test "a transfer debits the sender and credits the receiver" {
  Token.Transfer({ from: 0x01, to: 0x02, value: 100 })
  assertEq(Account.at(0x02).balance, 100)
  assert(Account.at(0x01).balance < 0)
}

A test fires events at your handlers against a mock store, then asserts on the resulting entity state.

Mocking contract calls

When a handler reads on-chain state, mock the call so the test stays deterministic:

test "approval writes the on-chain balance read via a contract call" {
  mockCall(ERC20.balanceOf(0x05), 4200)
  Token.Approval({ owner: 0x05, spender: 0x06, value: 1 })
  assertEq(Account.at(0x05).balance, 4200)
}

Running

redstart test              # run every test in the project
redstart test examples/erc20

Because the runner is native, the inner loop is near-instant — pair it with redstart dev to re-run check → build → test on every save.

Machine-readable diagnostics

For editors and AI-agent loops, redstart check --json emits diagnostics as JSON instead of prose:

redstart check --json
{
  "ok": false,
  "diagnostics": [
    {
      "severity": "error",
      "code": "E062",
      "message": "…",
      "help": "…",
      "file": "src/main.red",
      "line": 12,
      "column": 5,
      "offset": 240,
      "length": 7
    }
  ]
}

The process exits non-zero when ok is false, so an agent can read the error, apply the fix from help, and re-run — without parsing terminal output.

The eject path

Redstart’s entire bet is that its generated AssemblyScript is faithful: the canonical graph build toolchain compiles it unmodified, and it produces the same store a careful human would.

redstart build emits exactly the files a hand-written subgraph has:

build/
├── schema.graphql
├── subgraph.yaml
├── abis/…
└── src/mappings.ts

These are readable, idiomatic AssemblyScript and GraphQL — not an opaque intermediate format. You can run graph codegen and graph build on the output directly, and redstart deploy does exactly that under the hood:

redstart build → graph codegen → graph build → graph deploy

Why this matters

The eject path defuses the bus-factor objection to betting production infrastructure on a young language: if you ever abandon Redstart, you keep the generated code, and it keeps working with the standard toolchain. The cost of walking away is zero beyond losing the single-source-of-truth convenience.

Conformance

The claim is continuously verified. The conformance/ harness has three tiers:

  1. build — proves the eject path: graph codegen + graph build accept the generated subgraph unmodified (needs only Node). This runs in CI for every example on every push.
  2. deploy — deploys our subgraph and an independently hand-written reference to a local graph-node.
  3. diff — the kill/pivot gate: a field-level store-diff of the two at a fixed block. Our lowered AssemblyScript must produce a store identical to idiomatic hand-written AssemblyScript.

What’s checked for you

Redstart’s value is the errors you can’t hit. The semantic checker runs across every module and rejects the entire class of AssemblyScript subgraph footguns before a single block is indexed.

Footgun in hand-written AssemblyScriptIn Redstart
Forgetting .save()Impossible — entities are dirty-tracked and auto-saved
Arithmetic on a nullable value miscompilingCompile error — unwrap the Option first
Dereferencing a null load resultCompile error — match the Option<Entity>
Unguarded contract call aborting on revertCompile error — match the Result
== vs === confusionNot expressible — one equality, lowered correctly
Array prefill / length bugsHandled by the lowering
Manifest event signature drifting from the ABICompile error — signatures are derived by reference
Writing to a @derivedFrom fieldCompile error — derived fields are read-only
Schema/manifest/handler name driftImpossible — all three are projections of one AST

Other diagnostics the checker raises:

  • unknown source, event, entity, or type
  • missing required source settings (abi, network, address, startBlock)
  • a derived field whose back-reference doesn’t exist
  • a required field left uninitialised at creation
  • .value accessed without a surrounding match

The guiding principle: make the broken state unrepresentable, so the diagnostic is a parse or type error you see in your editor, not a revert you discover three hours into a sync.

MCP server (for AI agents)

redstart mcp starts a Model Context Protocol server over stdio, exposing the toolchain as tools an AI agent can call directly. The point is the write → check → fix loop: an agent authoring a subgraph edits .red, calls check, reads precisely-located diagnostics, and fixes them — without a human relaying compiler output.

Wiring it up

Register redstart mcp as an MCP server with your agent/host. For Claude Code:

$ claude mcp add redstart -- redstart mcp

Any MCP-capable client works — the transport is standard newline-delimited JSON-RPC 2.0 over stdio.

Tools

ToolArgumentsReturns
checkpath or source{ ok, diagnostics } — errors and lint warnings, each with a code, message, and file/line. ok is false only when there are errors.
explaincode (optional)The code’s meaning, the footgun it prevents, and the fix. Omit code to list every code.
buildpath, write (optional)The generated schema.graphql, subgraph.yaml, and src/mappings.ts (plus optimisation notes). write: true also writes them to disk.
testpathPer-test pass/fail for the project’s test blocks (native, no Docker).

check is the keystone. It returns the same structured diagnostics as redstart check --json, so the agent gets machine-readable feedback on every edit — and a parse or load failure comes back as an ordinary { ok: false, diagnostics } result rather than an error, so the loop never stalls on broken input. check also accepts inline source (a single .red file, no on-disk ABIs) for quick snippets.

Why this matters

Redstart’s guarantees — no nullable-arithmetic miscompiles, no non-deterministic host calls, Bytes ids, @derivedFrom relations — are only useful if they reach the author at the moment of writing. For a human that’s redstart check and the LSP; for an agent it’s this MCP server. The compiler already owns check, explain, build, and test; the MCP server just hands them to the agent.

Contributing & RFCs

Redstart is a public good and contributions are welcome.

Building from source

git clone https://github.com/nightswatchhq/redstart
cd redstart
cargo build
cargo test

The workspace is a conventional compiler pipeline split across crates: redstart-parserredstart-loaderredstart-checkerredstart-codegen, with redstart-test (native test interpreter), redstart-lsp (language server), and redstart-cli (the redstart binary) on top.

CI runs fmt, clippy -D warnings, the test suite, and the eject-path conformance build for every example on every push.

Language design: the RFC process

Substantial changes to the language — new syntax, new semantics, changes to the generated output — go through a lightweight RFC process so the design is recorded and discussed before it’s built. This is also our answer to the bus-factor question: the language is specified, not just implemented.

RFCs live in rfcs/ in the repository. To propose one:

  1. Copy rfcs/0000-template.md to rfcs/0000-my-feature.md.
  2. Fill it in — motivation, design, alternatives, drawbacks.
  3. Open a pull request. The number is assigned when it’s accepted.

See rfcs/README.md for the full lifecycle, and RFC-0001 for the foundational design rationale.