Quick start
In this walkthrough you register an agent, call it through the same command surface an API server uses, and read the frames of a streaming call. Everything runs in one process: an in-process worker client replaces NATS, and in-memory stores replace Postgres.
1. Create the project
Follow Installation to check out the workspace, then create a binary crate next to it:
cargo new my-agent-service
my-agent-service/Cargo.toml
[dependencies]
beam_core = { path = "../riverbeam/crates/beam_core" }
beam_domain = { path = "../riverbeam/crates/beam_domain" }
beam_stream = { path = "../riverbeam/crates/beam_stream" }
beam_types = { path = "../riverbeam/crates/beam_types" }
beam_worker = { path = "../riverbeam/crates/beam_worker" }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
uuid = { version = "1", features = ["v4", "serde"] }
2. Wire the pipeline
my-agent-service/src/main.rs
use std::sync::Arc;
use beam_core::{BeamConfig, RiverBeam};
use beam_domain::{BeamDomain, InvokePayload, LocalWorkerClient};
use beam_stream::{BeamStream, BroadcastTransport};
use beam_types::{ContentPart, StreamChunkType};
use beam_worker::BeamWorker;
use serde_json::Value;
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Stream frames fan out to every broadcast subscriber.
let transport = Arc::new(BroadcastTransport::new(64));
let mut frames = transport.subscribe();
// The pipeline, with one agent that echoes its input.
let mut beam = RiverBeam::new(BeamConfig::default());
beam.set_stream(Arc::new(BeamStream::new(transport.clone())));
beam.register_echo_agent("assistant");
// A worker, and an in-process client in place of NATS.
let worker = Arc::new(BeamWorker::new(Arc::new(beam)));
let domain = BeamDomain::new(LocalWorkerClient::new(worker));
let session_id = Uuid::new_v4();
let payload = |text: &str| InvokePayload {
agent_name: "assistant".into(),
content: vec![ContentPart::text(text)],
session_id,
model_override: None,
metadata: Value::Null,
stream: false,
auth_context: None,
};
// 3. Synchronous invoke: waits for the agent result.
let result = domain.invoke(payload("hello")).await?;
println!("invoke -> {}", result["content"][0]["text"]);
// 4. Streaming invoke: returns an acknowledgement at once.
let ack = domain.stream(payload("stream please")).await?;
println!("stream accepted, request_id = {}", ack.request_id);
while let Ok(published) = frames.recv().await {
println!("{:?} on {}", published.frame.chunk_type, published.channel);
if published.frame.chunk_type == StreamChunkType::Done {
break;
}
}
Ok(())
}
3. Run it
cargo run
invoke -> "Echo: hello"
stream accepted, request_id = 6b0c…
Chunk on ws.channel.<session_id>:<request_id>
Done on ws.channel.<session_id>:<request_id>
What happened
domain.invokebuilt anInvokeRequestand sent theinvoke_agenttask to the worker withrequest, a round trip.- The worker ran the invoke pipeline: it opened a session, interaction and run in the in-memory store, saved your message, built the context, and called the echo agent.
domain.streamsent the same task withsend, which does not wait. The pipeline published the agent's frames to the channelws.channel.{session_id}:{request_id}. The last frame has typedoneand carries the fullAgentResult.
Both calls used the same session_id, so they belong to one conversation, and the second call sees
the first turn in its history.
Next steps
- Replace the echo agent with a model-backed one: Agents, tools and skills.
- Add retrieval: Knowledge.
- Split the API and the worker over NATS, with Postgres storage: Worker and API.