fromDir — load a persona from disk
Rust · crate toolnexus · SPEC §7E · rust/src/agents/home.rs
pub fn from_dir(dir: impl AsRef<Path>, opts: FromDirOptions) -> Agent // toolnexus::agents#[derive(Clone, Default)]pub struct FromDirOptions { pub does: String, // routing description (default "persona agent from <dir>") pub name: String, // agent name (default the directory's base name) pub model: String, // model id (default "inherit") pub tools: Vec<Tool>, // extra tools beyond the memory builtin pub memory: Option<bool>,// None ⇒ enabled; Some(false) omits the memory tool}pub fn start_agent(a: &Agent, opts: StartOptions) -> Result<StartedAgent, String>from_dir is the one-call persona constructor: the directory is the agent. It calls
compose_soul to build the soul from dir’s bootstrap files,
wires memory_tool(dir) into the tool list (unless
opts.memory is Some(false)), and hands both to agents::new.
The returned Agent runs like any other — run for a one-shot, as_tool to drop it into a
classic toolkit, or start_agent for a heartbeat loop (each interval posts a tick to the
agent’s own inbox and, when idle, wakes it; a HEARTBEAT_OK reply stays silent).
When to use it
Section titled “When to use it”Reach for from_dir whenever a persona’s identity lives in files on disk — the openclaw
convention of AGENTS.md / SOUL.md / IDENTITY.md / USER.md / TOOLS.md /
HEARTBEAT.md / MEMORY.md. It’s the standard way to boot a long-lived, home-directory-backed
agent: point it at a folder, get back something runnable, with durable memory already wired.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a persona from a bare directory
Section titled “1. The smallest useful call — a persona from a bare directory”No bootstrap files at all still works: the soul is empty, the name defaults to the directory’s base name, and the memory tool is wired anyway. The model is an in-process stand-in.
use std::sync::Arc;
use toolnexus::agents::{FromDirOptions, Options, from_dir};use toolnexus::client::{GenerateFn, InProcessRequest, InProcessResponse};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let dir = std::env::temp_dir().join(format!("kip-fd1-{}", std::process::id())); std::fs::create_dir_all(&dir)?;
let kip = from_dir(&dir, FromDirOptions { model: "m-kip".into(), ..Default::default() }); assert_eq!(kip.name, dir.file_name().unwrap().to_string_lossy());
let model: GenerateFn = Arc::new(|_req: InProcessRequest| { Box::pin(async { Ok(InProcessResponse { content: "hi".into(), ..Default::default() }) }) }); let (r, _rt) = kip.run(Options { in_process: Some(model), ..Default::default() }, "hello").await; assert_eq!((r.status.as_str(), r.text.as_str()), ("done", "hi"));
std::fs::remove_dir_all(&dir)?; println!("ok: {} answered {}", kip.name, r.text); Ok(())}2. The realistic case — bootstrap files shape the soul, and the model answers with it
Section titled “2. The realistic case — bootstrap files shape the soul, and the model answers with it”SOUL.md on disk becomes part of the request the stub model receives — proving the composed
soul actually reaches the provider as the system prompt.
use std::sync::Arc;use std::sync::atomic::{AtomicBool, Ordering};
use toolnexus::agents::{FromDirOptions, Options, from_dir};use toolnexus::client::{GenerateFn, InProcessRequest, InProcessResponse};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let dir = std::env::temp_dir().join(format!("archivist-fd2-{}", std::process::id())); std::fs::create_dir_all(&dir)?; std::fs::write(dir.join("SOUL.md"), "You are Archivist, terse and precise.")?;
let archivist = from_dir( &dir, FromDirOptions { name: "archivist".into(), does: "keeps records".into(), model: "m-archivist".into(), ..Default::default() }, );
let saw_soul = Arc::new(AtomicBool::new(false)); let seen = saw_soul.clone(); let model: GenerateFn = Arc::new(move |req: InProcessRequest| { if req.body.to_string().contains("Archivist, terse and precise") { seen.store(true, Ordering::SeqCst); } Box::pin(async { Ok(InProcessResponse { content: "logged".into(), ..Default::default() }) }) }); let (r, _rt) = archivist .run(Options { in_process: Some(model), ..Default::default() }, "file this record") .await; assert_eq!((r.status.as_str(), r.text.as_str()), ("done", "logged")); assert!(saw_soul.load(Ordering::SeqCst), "the composed SOUL.md text must reach the model");
std::fs::remove_dir_all(&dir)?; println!("ok: {} ran with its composed soul", archivist.name); Ok(())}3. The full surface — extra tools, and turning the memory builtin off
Section titled “3. The full surface — extra tools, and turning the memory builtin off”memory: Some(false) yields a read-only persona: no memory tool at all, even though a home
directory is wired. Extra tools in tools are declared either way.
use std::sync::Arc;
use serde_json::json;use toolnexus::agents::{FromDirOptions, Options, from_dir};use toolnexus::client::{GenerateFn, InProcessRequest, InProcessResponse};use toolnexus::define_tool;
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let dir = std::env::temp_dir().join(format!("readonly-fd3-{}", std::process::id())); std::fs::create_dir_all(&dir)?;
let clock = define_tool("clock", "Current time", Some(json!({"type": "object"})), |_args, _ctx| async { Ok::<_, String>("12:00".to_string()) }); let readonly = from_dir( &dir, FromDirOptions { name: "readonly".into(), model: "m-readonly".into(), tools: vec![clock], memory: Some(false), ..Default::default() }, ); // Memory enabled (the default) DOES get the memory tool. let with_memory = from_dir(&dir, FromDirOptions { name: "withmemory".into(), model: "m-with".into(), ..Default::default() });
// The stub model reports which tools the request declared. let model: GenerateFn = Arc::new(|req: InProcessRequest| { let mut names: Vec<String> = req .tools .iter() .filter_map(|t| t["function"]["name"].as_str().map(String::from)) .collect(); names.sort(); Box::pin(async move { Ok(InProcessResponse { content: names.join(","), ..Default::default() }) }) });
let (r1, _) = readonly.run(Options { in_process: Some(model.clone()), ..Default::default() }, "go").await; assert_eq!(r1.text, "clock", "readonly persona must declare no memory tool"); let (r2, _) = with_memory.run(Options { in_process: Some(model), ..Default::default() }, "go").await; assert_eq!(r2.text, "memory", "default persona must declare the memory tool");
std::fs::remove_dir_all(&dir)?; println!("ok: memory: Some(false) omits the tool; the default wires it"); Ok(())}FromDirOptions fields
Section titled “FromDirOptions fields”| Field | Type | Default | What it does |
|---|---|---|---|
does |
String |
"persona agent from <dir>" |
The routing description the delegating model (or a parent’s task tool) sees. |
name |
String |
the directory’s base name | The agent’s name. |
model |
String |
"inherit" |
The model id passed to agents::Spec.model. |
tools |
Vec<Tool> |
none | Extra tools beyond the memory builtin. |
memory |
Option<bool> |
None (enabled) |
Some(false) omits memory_tool(dir) — a read-only persona. |
See also
Section titled “See also”compose_soul— Build a persona’s system prompt from its home directory: identity, memory, skills.memory_tool— The opt-in built-in that lets a persona write durable notes to its own home.agents::new— The lower-level constructorfrom_dirwraps.