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.graphql | Redstart |
|---|---|
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 Y | entity 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
| AssemblyScript | Redstart |
|---|---|
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 = null | entity.field = None (the field must be Option<T>) |
x ? a : b | let 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.address | the contract that emitted the event |
event.id | a unique id derived from the transaction hash and log index |
event.logIndex, event.transactionLogIndex | position within the block / transaction |
event.block.number, .timestamp, .hash | the block |
event.transaction.hash, .from, .to, .value, .gasPrice | the 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:
- 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); - 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>andId<String>are the two forms graph-node supports.[Transfer] derived from fromis a derived (virtual) field: it’s computed from thefromfield onTransfer, 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
Optionis rejected — you must unwrap first. - Dereferencing a nullable host return is rejected.
store.get-style loads (Entity.load,loadInBlock) andipfs.catreturnOption<T>; you mustmatchthem 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:
build— proves the eject path:graph codegen+graph buildaccept the generated subgraph unmodified (needs only Node). This runs in CI for every example on every push.deploy— deploys our subgraph and an independently hand-written reference to a local graph-node.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 AssemblyScript | In Redstart |
|---|---|
Forgetting .save() | Impossible — entities are dirty-tracked and auto-saved |
| Arithmetic on a nullable value miscompiling | Compile error — unwrap the Option first |
Dereferencing a null load result | Compile error — match the Option<Entity> |
| Unguarded contract call aborting on revert | Compile error — match the Result |
== vs === confusion | Not expressible — one equality, lowered correctly |
| Array prefill / length bugs | Handled by the lowering |
| Manifest event signature drifting from the ABI | Compile error — signatures are derived by reference |
Writing to a @derivedFrom field | Compile error — derived fields are read-only |
| Schema/manifest/handler name drift | Impossible — 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
.valueaccessed without a surroundingmatch
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
| Tool | Arguments | Returns |
|---|---|---|
check | path or source | { ok, diagnostics } — errors and lint warnings, each with a code, message, and file/line. ok is false only when there are errors. |
explain | code (optional) | The code’s meaning, the footgun it prevents, and the fix. Omit code to list every code. |
build | path, write (optional) | The generated schema.graphql, subgraph.yaml, and src/mappings.ts (plus optimisation notes). write: true also writes them to disk. |
test | path | Per-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-parser → redstart-loader → redstart-checker → redstart-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:
- Copy
rfcs/0000-template.mdtorfcs/0000-my-feature.md. - Fill it in — motivation, design, alternatives, drawbacks.
- 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.