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)
}
assertions passed

#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")
}
assertions passed

#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]")
}
assertions passed

#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")
}
assertions passed

#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)
}
assertions passed

#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")
}
assertions passed

#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")
}
assertions passed

#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")
}
assertions passed

#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.