Guide
From one call to a team behind a protocol. Every block on this page is a test in the repository, and every output is what it printed.
#1. Install
moon add moonbitstack/moonkoog
A run needs a provider; the examples use moonmuse/fake, a scripted provider that answers from a list, so nothing on this page needs a key.
#2. A first agent
An agent is a config and a provider. The config says which model, what the instructions are, which tools may be called, and which strategy the run follows — the default is the single-run loop.
async test "a first agent" {
let provider = @fake.Fake::text("A state graph is a program made of nodes.")
let agent = @moonkoog.create_agent(
provider,
@moonkoog.AgentConfig::new("scripted").with_instructions("Answer in one sentence."),
)
assert_eq(agent.run("What is a state graph?"), "A state graph is a program made of nodes.")
assert_eq(provider.seen()[0].messages[0].role, @moonmuse.System)
}
#3. Tools
A tool is a descriptor and a body. The body gets the arguments and the run's context, and returns the text the model reads.
fn shout() -> @moonkoog.ToolSpec {
@moonkoog.ToolSpec::new(
name="shout",
description="Repeat the text in capitals.",
schema=Json::object(
Map([
("type", Json::string("object")),
("properties", Json::object(Map([
("text", Json::object(Map([("type", Json::string("string"))]))),
]))),
]),
),
run=fn(args, _context) {
match args {
Object(fields) =>
match fields.get("text") {
Some(String(text)) => text.to_upper()
_ => "error: no text"
}
_ => "error: no text"
}
},
)
}
async test "a tool call" {
let provider = @fake.Fake::new([
@moonmuse.Reply::new(
@moonmuse.Message::new(@moonmuse.Assistant, [
@moonmuse.Part::call("c1", "shout", Json::object(Map([("text", Json::string("hello"))]))),
]),
finish=@moonmuse.Finish::Calls,
),
@moonmuse.Reply::new(@moonmuse.Message::assistant("HELLO")),
])
let agent = @moonkoog.create_agent(
provider,
@moonkoog.AgentConfig::new("scripted").with_tools([shout()]),
)
assert_eq(agent.run("shout hello"), "HELLO")
}
#4. The pipeline
Middleware wraps the model call or the tool call. The ones here cap the calls a run may spend and scrub an address before it leaves the process.
async test "a pipeline" {
let provider = @fake.Fake::text("ok")
let agent = @moonkoog.create_agent(
provider,
@moonkoog.AgentConfig::new("scripted")
.with_middleware(@middleware.ModelCallLimit::new(limit=2))
.with_middleware(@middleware.Pii::standard()),
)
assert_eq(agent.run("write to leo@example.com"), "ok")
assert_eq(provider.seen()[0].messages[1].text_content(), "write to [email]")
}
#5. Checkpoints and resumption
A thread is a name. Pass a saver and the run checkpoints each node boundary under that name; pass the same name again and the run continues from what it had, budgets included.
async test "a checkpointed thread" {
let provider = @fake.Fake::text("remembered")
let saver = @moonstate.MemorySaver::new()
let agent = @moonkoog.create_agent(provider, @moonkoog.AgentConfig::new("scripted"), saver~)
assert_eq(agent.run("first", thread="t1"), "remembered")
let snapshot = agent.state("t1").unwrap()
assert_eq(snapshot.state.text(), "remembered")
}
#6. Planning
Plan-and-execute writes the steps first, then works through them. A step whose tool call failed sends the run back to the planner.
async test "a plan" {
let provider = @fake.Fake::new([
@moonmuse.Reply::new(@moonmuse.Message::assistant("[\"look it up\", \"report it\"]")),
@moonmuse.Reply::new(@moonmuse.Message::assistant("six times seven is forty-two")),
@moonmuse.Reply::new(@moonmuse.Message::assistant("Reported: forty-two.")),
])
let agent = @moonkoog.create_agent(
provider,
@moonkoog.plan_and_execute(@moonkoog.AgentConfig::new("scripted")),
)
assert_eq(agent.run("Work out six times seven."), "Reported: forty-two.")
assert_eq(provider.hits(), 3)
}
#7. Teams
A team is a strategy whose nodes are agents. A member hands the conversation on with the handoff tool; the router follows the marker it leaves.
async test "a team" {
let first = @fake.Fake::new([
@moonmuse.Reply::new(
@moonmuse.Message::new(@moonmuse.Assistant, [
@moonmuse.Part::call("c1", "handoff", Json::object(Map([("to", Json::string("writer"))]))),
]),
finish=@moonmuse.Finish::Calls,
),
])
let second = @fake.Fake::new([@moonmuse.Reply::new(@moonmuse.Message::assistant("written"))])
let researcher = @moonkoog.create_agent(
first,
@moonkoog.AgentConfig::new("scripted", name="researcher")
.with_tools([@moonkoog.handoff_tool(["writer"])]),
)
let writer = @moonkoog.create_agent(
second,
@moonkoog.AgentConfig::new("scripted", name="writer"),
)
let team = @moonkoog.create_agent(
first,
@moonkoog.swarm(@moonkoog.AgentConfig::new("scripted", name="team"), [researcher, writer]),
)
assert_eq(team.run("write a line"), "written")
}
#8. Behind a protocol
The adapters are translations. This one serves an agent over MCP and calls it back over the in-memory transport, which is the same code path a stdio server takes.
async test "an agent behind MCP" {
let served = @moonkoog.create_agent(
@fake.Fake::text("from the served agent"),
@moonkoog.AgentConfig::new("scripted", name="helper"),
)
let client = @client.Client::connect(@client.Memory::new(@mcp.serve(served)))
let specs = @mcp.tools(client)
assert_eq(specs.length(), 1)
assert_eq(specs[0].tool.name, "chat")
}
#9. Traces
The otel package turns a run into two spans — the agent and each tool call — with the attribute names a GenAI collector expects.
async test "a trace" {
let sink = @trace.Recorder::new()
let tracer = @trace.Tracer::new(
rand=n => Bytes::make(n, b'\x07'),
sink~,
sampler=@trace.always,
)
let otel = @otel.Otel::new(tracer~, now=fn() { 0L })
let agent = @moonkoog.create_agent(
@fake.Fake::text("traced"),
@moonkoog.AgentConfig::new("scripted").with_events(otel.install(@moonkoog.EventHandler::new())),
)
assert_eq(agent.run("hello"), "traced")
assert_eq(sink.spans()[0].name, "invoke_agent agent")
}
#Where to read next
The API reference has every public item. The tasks a run can be interrupted on, the middleware's contracts, and the way a strategy is data are all in the doc comments there.