docs(adopting): guides for using, declaring and serving AI tools (#93)

* docs(adopting): guides for using, declaring and serving AI tools, with every code block run as a test

* docs(adopting): address review: keyed register example, cancel that may be unsupported, indented fences, guide tests in the README

* docs(adopting): cancel may be unsupported in the provider table and app guide; a job may end straight from Queued

---------

Co-authored-by: scratch <s@s>
This commit is contained in:
Jeremy
2026-10-23 09:53:29 +01:00
committed by GitHub
parent 1a02afd6be
commit fe5748da8d
12 changed files with 1932 additions and 1 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Layout and rules mirror craft-libs: edition 2024, `MIT OR Apache-2.0`, no `unsaf
| `craft-ai-comfy-env` | Finds an existing ComfyUI Desktop install, inventories its model files and node packs, downloads missing model files into it. |
| `craft-ai-egui` | Theme-neutral egui widgets: status, model picker, consent prompt, job progress, connection panel, history. Needs Rust 1.95. |
Building on this layer (add a service, a tool, or use one from an app): [docs/ADOPT-A-TOOL.md](docs/ADOPT-A-TOOL.md).
Building on this layer (use a tool in your app, declare a new tool, or bring a service or model): start at [docs/adopting](docs/adopting/README.md). The short version is [docs/ADOPT-A-TOOL.md](docs/ADOPT-A-TOOL.md).
Which crate owns what, and what stays in each app: [docs/LAYOUT.md](docs/LAYOUT.md). The shared-tool
contract and its limits: [docs/PLATFORM-CONTRACT.md](docs/PLATFORM-CONTRACT.md). Custom connections,
@@ -0,0 +1,244 @@
//! Runs every Rust block in `docs/adopting/use-in-your-app.md`.
//!
//! Each test is one block. `adopting_drift` fails if the guide and the block differ.
// A block shows only the variables it needs, so the rest of the scene stays unused.
#![allow(unused_variables)]
#[path = "support/adopting_scene.rs"]
mod adopting_scene;
use adopting_scene::{Scene, TestResult, scripted_client, tighten_request};
// Compiles only: it would touch the person's real settings if it ran.
#[allow(dead_code)]
fn standard_client() -> TestResult {
// snippet: standard_client
use craft_ai_client::AiClient;
use craft_ai_core::AppId;
// Reads the shared connections file the launcher writes, and keys from the environment
// and then the OS keychain.
let client = AiClient::standard(AppId::new("wordcraft")?)?;
// end snippet
Ok(())
}
#[test]
fn catalogue_role() -> TestResult {
// snippet: catalogue_role
use craft_ai_features::builtin;
let select_subject = builtin()
.into_iter()
.find(|feature| feature.id == "select-subject")
.and_then(|feature| feature.role())
.ok_or("the catalogue has no such tool")?;
assert_eq!(select_subject.capability.as_str(), "image.segment");
// end snippet
Ok(())
}
#[test]
fn status() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: status
use craft_ai_client::RoleStatus;
match client.status(&role) {
RoleStatus::Ready(ready) => {
// Enable the tool. You can show which model will do the work.
assert_eq!(ready.model_label, "Tidy 1");
}
RoleStatus::Unavailable { reason } => {
// Grey the tool out and show `reason` exactly as it is.
println!("{reason}");
}
}
// end snippet
Ok(())
}
#[test]
fn request() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: request
use craft_ai_core::{InputRole, Payload, Request};
let request = Request::new(role.capability.clone())
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
.with_param("tone", serde_json::json!("plain"));
// end snippet
Ok(())
}
#[test]
fn plan() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: plan
let plan = client.plan(&role, request)?;
println!("{} will use {}", role.label, plan.connection_label());
// end snippet
Ok(())
}
#[test]
fn consent() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(true)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: consent
use craft_ai_core::{ConsentLedger, ConsentTerm, ErrorKind};
let plan = client.plan(&role, request)?;
let mut saved = ConsentLedger::default(); // in a real app: load it, see below
// Without consent the job does not start, and no key is read.
let refused = client.start(&plan, &saved).expect_err("must refuse");
assert_eq!(refused.kind, ErrorKind::ConsentRequired);
if plan.needs_consent(&saved) {
let d = plan.disclosure();
println!("Send {} item(s) to {}?", d.items.len(), d.destination);
// The person answers with one of ConsentTerm::ALL, shortest first.
let now = 1_800_000_000; // in a real app: the current unix time in seconds
let ledger_for_this_job = saved.agree(d, ConsentTerm::ThisTime, now);
assert!(client.start(&plan, &ledger_for_this_job).is_ok());
// "This time" is not remembered: `saved` is unchanged.
assert!(plan.needs_consent(&saved));
}
// end snippet
Ok(())
}
#[test]
fn job() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: job
use std::time::Duration;
use craft_ai_core::{ConsentLedger, Event, JobState};
let plan = client.plan(&role, request)?;
let job = client.start(&plan, &ConsentLedger::default())?;
let final_state = loop {
let snapshot = job.poll()?;
for event in &snapshot.events {
match event {
Event::TextDelta { text } => print!("{text}"),
Event::Output(output) => println!("result: {:?}", output.payload),
Event::Notice { message } => println!("note: {message}"),
Event::ToolCall { .. } => {}
}
}
if snapshot.state.is_terminal() {
break snapshot.state;
}
std::thread::sleep(Duration::from_millis(5)); // in an app: poll from your own timer
};
assert_eq!(final_state, JobState::Succeeded);
// end snippet
Ok(())
}
#[test]
fn missing_model() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: missing_model
use craft_ai_client::{AiClient, ToolDesk};
use craft_ai_core::AppId;
let desk = ToolDesk::new(
AiClient::new(AppId::new("notes")?, shared.clone(), keys.clone()),
shared.clone(),
None,
);
let popup = desk.missing_model(&role, &craft_ai_features::builtin())?;
// `Some` means the tool cannot run in this app; `None` means it can.
assert!(popup.is_some()); // the "notes" app ships no adapter for "tidy"
// end snippet
Ok(())
}
#[test]
fn two_apps() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
let client = scripted_client(&shared, &keys)?;
let request = tighten_request(&role);
// snippet: two_apps
use craft_ai_client::{AiClient, RoleStatus};
use craft_ai_core::AppId;
// `client` ships an adapter for the chosen service, so the suite default is ready with no setup.
assert!(matches!(client.status(&role), RoleStatus::Ready(_)));
// Another app reads the same store but ships no adapter, so it is unavailable, and says so.
// `shared` and `keys` are the temporary store and key store this test built.
let second = AiClient::new(AppId::new("lightcraft")?, shared.clone(), keys.clone());
assert!(matches!(
second.status(&role),
RoleStatus::Unavailable { .. }
));
// end snippet
Ok(())
}
+203
View File
@@ -0,0 +1,203 @@
//! Keeps the adoption guides and the code that is tested identical.
//!
//! Every ```rust block in `docs/adopting` must sit under a `<!-- snippet: NAME -->` line, and the
//! same code (after removing its indent) must sit between `// snippet: NAME` and
//! `// end snippet` in `adopting_app_guide.rs` or `adopting_tool_guides.rs`, where it runs. So a
//! reader can trust that what a guide shows has been run. Edit both together, and format the test
//! file with rustfmt first, because the guide shows the formatted code.
use std::collections::BTreeMap;
const GUIDES: &[(&str, &str)] = &[
(
"README.md",
include_str!("../../../docs/adopting/README.md"),
),
(
"use-in-your-app.md",
include_str!("../../../docs/adopting/use-in-your-app.md"),
),
(
"declare-a-tool.md",
include_str!("../../../docs/adopting/declare-a-tool.md"),
),
(
"bring-a-service.md",
include_str!("../../../docs/adopting/bring-a-service.md"),
),
(
"checklist.md",
include_str!("../../../docs/adopting/checklist.md"),
),
(
"troubleshooting.md",
include_str!("../../../docs/adopting/troubleshooting.md"),
),
];
const TESTS: &[(&str, &str)] = &[
(
"adopting_app_guide.rs",
include_str!("adopting_app_guide.rs"),
),
(
"adopting_tool_guides.rs",
include_str!("adopting_tool_guides.rs"),
),
];
/// Lines of `text` with line endings and trailing spaces removed, so a CRLF checkout compares equal.
fn lines(text: &str) -> Vec<String> {
text.lines().map(|l| l.trim_end().to_owned()).collect()
}
/// Removes the indent shared by every non-blank line.
fn dedent(block: &[String]) -> String {
let cut = block
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
let cut_line = |l: &String| l.get(cut..).unwrap_or("").to_owned();
block.iter().map(cut_line).collect::<Vec<_>>().join("\n")
}
/// A code fence line: its marker (`` ` `` or `~`), its length and the text after it. Markdown allows
/// the fence to be indented, as it is under a list item.
fn fence(line: &str) -> Option<(char, usize, &str)> {
let text = line.trim_start();
let marker = text.chars().next().filter(|c| matches!(c, '`' | '~'))?;
let len = text.chars().take_while(|&c| c == marker).count();
(len >= 3).then(|| (marker, len, text.get(len..).unwrap_or("").trim()))
}
fn opens_rust(line: &str) -> bool {
fence(line).is_some_and(|(_, _, info)| info.starts_with("rust"))
}
/// The named blocks in a guide. Panics, naming the file, on a Rust block with no name above it.
fn guide_snippets(file: &str, text: &str) -> Vec<(String, String)> {
let all = lines(text);
let mut out = Vec::new();
let mut i = 0;
while i < all.len() {
if opens_rust(&all[i]) {
panic!(
"{file} line {}: a Rust block needs a `<!-- snippet: NAME -->` line above it",
i + 1
);
}
if let Some(name) = all[i]
.trim_start()
.strip_prefix("<!-- snippet: ")
.and_then(|r| r.strip_suffix(" -->"))
{
assert!(
all.get(i + 1).is_some_and(|l| opens_rust(l)),
"{file}: snippet {name} is not followed by a Rust block"
);
let (marker, len, _) = fence(&all[i + 1]).unwrap_or(('`', 3, ""));
let start = i + 2;
// The closing fence uses the opening marker, is at least as long, and has no text after it.
let end = all[start..]
.iter()
.position(|l| {
fence(l).is_some_and(|(m, n, info)| m == marker && n >= len && info.is_empty())
})
.map(|n| start + n)
.unwrap_or_else(|| panic!("{file}: snippet {name} is never closed"));
out.push((name.to_owned(), dedent(&all[start..end])));
i = end;
}
i += 1;
}
out
}
/// The tested blocks in a test file.
fn test_snippets(text: &str) -> Vec<(String, String)> {
let all = lines(text);
let mut out = Vec::new();
let mut i = 0;
while i < all.len() {
if let Some(name) = all[i].trim().strip_prefix("// snippet: ") {
let start = i + 1;
let end = all[start..]
.iter()
.position(|l| l.trim() == "// end snippet")
.map(|n| start + n)
.unwrap_or_else(|| panic!("snippet {name} has no `// end snippet`"));
out.push((name.to_owned(), dedent(&all[start..end])));
i = end;
}
i += 1;
}
out
}
fn collect(
sources: &[(&str, &str)],
read: fn(&str, &str) -> Vec<(String, String)>,
) -> BTreeMap<String, (String, String)> {
let mut map = BTreeMap::new();
for (file, text) in sources {
for (name, code) in read(file, text) {
let clash = map.insert(name.clone(), (file.to_string(), code));
assert!(clash.is_none(), "snippet name {name} is used twice");
}
}
map
}
#[test]
fn every_guide_block_is_the_code_that_runs() {
let guides = collect(GUIDES, guide_snippets);
let tests = collect(TESTS, |_, text| test_snippets(text));
assert!(!guides.is_empty(), "no snippets found in the guides");
for (name, (file, shown)) in &guides {
let (test_file, run) = tests
.get(name)
.unwrap_or_else(|| panic!("{file}: snippet {name} has no test that runs it"));
assert_eq!(
shown, run,
"{file}: snippet {name} differs from the code in {test_file}"
);
}
for (name, (test_file, _)) in &tests {
assert!(
guides.contains_key(name),
"{test_file}: snippet {name} is not shown in any guide"
);
}
}
#[test]
fn the_comparison_notices_a_changed_block() {
// Proves the check can fail: the same name with different code must not compare equal.
let guide = guide_snippets("g.md", "<!-- snippet: a -->\n```rust\nlet x = 1;\n```\n");
let run = test_snippets("fn a() {\n // snippet: a\n let x = 2;\n // end snippet\n}\n");
assert_eq!(guide[0].0, run[0].0);
assert_ne!(guide[0].1, run[0].1);
}
#[test]
fn a_block_under_a_list_item_is_still_found() {
let guide = "- step\n\n <!-- snippet: a -->\n ```rust\n let x = 1;\n ```\n";
let found = guide_snippets("g.md", guide);
assert_eq!(found, vec![("a".to_owned(), "let x = 1;".to_owned())]);
}
#[test]
#[should_panic(expected = "needs a `<!-- snippet: NAME -->` line")]
fn an_indented_rust_block_with_no_name_is_refused() {
guide_snippets("g.md", "- step\n\n ```rust\n let x = 1;\n ```\n");
}
#[test]
fn a_longer_or_other_marker_fence_does_not_close_early() {
let guide = "<!-- snippet: a -->\n````rust\nlet s = 1;\n```\nlet t = 2;\n````\n";
let found = guide_snippets("g.md", guide);
assert_eq!(found[0].1, "let s = 1;\n```\nlet t = 2;");
}
@@ -0,0 +1,296 @@
//! Runs every Rust block in `docs/adopting/declare-a-tool.md` and
//! `docs/adopting/bring-a-service.md`.
//!
//! Each test is one block. `adopting_drift` fails if the guide and the block differ.
// A block shows only the variables it needs, so the rest of the scene stays unused.
#![allow(unused_variables)]
#[path = "support/adopting_scene.rs"]
mod adopting_scene;
use adopting_scene::{Scene, TestResult};
#[test]
fn well_known() -> TestResult {
// snippet: well_known
use craft_ai_core::capability::well_known;
let known: Vec<String> = well_known().iter().map(|c| c.to_string()).collect();
assert!(known.contains(&"image.inpaint".to_string()));
assert!(known.contains(&"image.segment".to_string()));
// end snippet
Ok(())
}
#[test]
fn declare_and_choose() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
// snippet: declare_and_choose
use craft_ai_core::{
CapabilityId, CapabilitySpec, ConnectionId, InputRole, ModelId, ModelInfo,
};
use craft_ai_core::{Role, RoleId};
// `launcher` is a `ToolDesk`, and `shared` is the connections file. See the tests for the scene.
// 1. The declaration.
let describe = Role {
id: RoleId::new("describe-photo")?,
label: "Describe photograph".into(),
capability: CapabilityId::parse("image.describe")?,
inputs: vec![InputRole::Source],
};
launcher.register_tool(describe.clone())?;
// 2. A model that can do it, offered by a connection the person already has.
let vision = ModelInfo::new(ModelId::new("vision-1")?, "Vision 1").with(
CapabilitySpec::new(describe.capability.clone())
.accepting(InputRole::Source, 1)
.requiring(InputRole::Source),
);
let service = serde_json::from_value(serde_json::json!({
"id": "vision-service", "label": "Vision (on this computer)", "provider": "vision",
"locality": "local", "auth": {"kind": "none"}, "billing": "free",
"models": [vision.clone()],
}))?;
shared.update(|store| store.connections.push(service))?;
// 3. The suite-wide choice. It is checked before anything is written.
launcher.choose(&describe, &ConnectionId::new("vision-service")?, &vision.id)?;
assert_eq!(launcher.custom_tools()?.len(), 2); // "tighten-text" from the scene, and this one
// end snippet
Ok(())
}
#[test]
fn id_is_a_contract() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
// snippet: id_is_a_contract
use craft_ai_client::{AiClient, RoleStatus};
use craft_ai_core::{AppId, CapabilityId, InputRole, Role, RoleId};
let honest = Role {
id: RoleId::new("shorten")?,
label: "Shorten".into(),
capability: CapabilityId::parse("text.complete")?,
inputs: vec![InputRole::Prompt],
};
launcher.register_tool(honest.clone())?;
// Another app reuses the id for something else. It is refused, not quietly accepted.
let imposter = Role {
capability: CapabilityId::parse("image.generate")?,
..honest
};
let app = AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone());
match app.status(&imposter) {
RoleStatus::Unavailable { reason } => assert!(reason.contains("does not match")),
RoleStatus::Ready(_) => panic!("a changed contract must not resolve"),
}
// end snippet
Ok(())
}
#[test]
fn describe_request() -> TestResult {
// snippet: describe_request
use craft_ai_core::{CapabilityId, InputRole, Payload, Request};
let request = Request::new(CapabilityId::parse("image.describe")?).with_input(
InputRole::Source,
Payload::inline("image/png", vec![0x89, b'P', b'N', b'G'])?,
);
assert_eq!(request.count(&InputRole::Source), 1);
// end snippet
Ok(())
}
#[test]
fn shouter_provider() -> TestResult {
// snippet: shouter_provider
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use craft_ai_core::payload::Limits;
use craft_ai_core::{
AiError, CapabilityId, CapabilitySpec, ErrorKind, Event, InputRole, JobId, JobSnapshot,
JobState, ModelId, ModelInfo, Output, Payload, Provider, Request,
};
#[derive(Clone, Copy, PartialEq)]
enum Phase {
Queued,
Running,
Done,
Cancelled,
}
struct Job {
phase: Phase,
answer: String,
}
struct Shouter {
model: ModelInfo,
jobs: Mutex<HashMap<String, Job>>,
next: AtomicU32,
}
impl Shouter {
fn new() -> Result<Self, AiError> {
let capability = CapabilityId::parse("text.complete").map_err(bad)?;
let spec = CapabilitySpec::new(capability)
.accepting(InputRole::Prompt, 1)
.requiring(InputRole::Prompt);
let id = ModelId::new("shout-1").map_err(bad)?;
Ok(Self {
model: ModelInfo::new(id, "Shout 1").with(spec),
jobs: Mutex::new(HashMap::new()),
next: AtomicU32::new(1),
})
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Job>>, AiError> {
self.jobs
.lock()
.map_err(|_| AiError::new(ErrorKind::Internal, "the job list is unavailable"))
}
}
fn bad(e: impl std::fmt::Display) -> AiError {
AiError::new(ErrorKind::Internal, e.to_string())
}
fn unknown() -> AiError {
AiError::invalid("that job does not exist")
}
impl Provider for Shouter {
fn models(&self) -> Result<Vec<ModelInfo>, AiError> {
Ok(vec![self.model.clone()])
}
fn submit(&self, model: &ModelId, request: &Request) -> Result<JobId, AiError> {
// Refuse first. Nothing has been sent anywhere yet.
if *model != self.model.id {
return Err(AiError::unsupported("this service has no such model"));
}
let spec = self.model.spec_for(&request.capability).ok_or_else(|| {
AiError::unsupported(format!("this model cannot do {}", request.capability))
})?;
request.validate(spec, &Limits::default())?;
let answer = match request.inputs.first().map(|i| &i.payload) {
Some(Payload::Text { text }) => text.to_uppercase(),
_ => return Err(AiError::invalid("a text prompt is needed")),
};
let name = format!("job-{}", self.next.fetch_add(1, Ordering::SeqCst));
self.lock()?.insert(
name.clone(),
Job {
phase: Phase::Queued,
answer,
},
);
JobId::new(&name).map_err(bad)
}
fn poll(&self, job: &JobId) -> Result<JobSnapshot, AiError> {
let mut jobs = self.lock()?;
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
Ok(match entry.phase {
Phase::Queued => {
entry.phase = Phase::Running;
JobSnapshot::new(JobState::Running { percent: Some(50) })
}
Phase::Running => {
entry.phase = Phase::Done;
let mut done = JobSnapshot::new(JobState::Succeeded);
done.events.push(Event::Output(Output {
payload: Payload::text(entry.answer.clone()),
label: None,
}));
done // the answer is delivered once
}
Phase::Done => JobSnapshot::new(JobState::Succeeded),
Phase::Cancelled => JobSnapshot::new(JobState::Cancelled),
})
}
fn cancel(&self, job: &JobId) -> Result<(), AiError> {
let mut jobs = self.lock()?;
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
if matches!(entry.phase, Phase::Queued | Phase::Running) {
entry.phase = Phase::Cancelled;
}
Ok(())
}
}
// Prove it with the checks every provider must pass.
use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
let provider = Shouter::new()?;
let model = provider.model.id.clone();
let capability = CapabilityId::parse("text.complete")?;
let good = Request::new(capability.clone()).with_input(InputRole::Prompt, Payload::text("hi"));
let no_prompt = Request::new(capability);
run_lifecycle(&provider, &model, &good)?;
run_cancel(&provider, &model, &good)?;
run_unknown_job(&provider)?;
run_rejects(&provider, &model, &no_prompt, ErrorKind::InvalidRequest)?;
// end snippet
Ok(())
}
#[test]
fn register_adapter() -> TestResult {
let Scene {
_dir,
shared,
keys,
role,
model,
launcher,
} = Scene::new(false)?;
// snippet: register_adapter
use std::sync::Arc;
use craft_ai_client::{AiClient, ProviderFactory};
use craft_ai_core::mock::{MockProvider, Outcome, Script};
use craft_ai_core::{AppId, Provider};
let mut factory = ProviderFactory::standard();
factory.register("acme", |connection, key| {
// `key` is already read from the key store. The adapter never touches a keychain.
// A real adapter sends `key` with each request. This stand-in has nowhere to send it.
let _ = key;
let script = Script {
running_polls: 1,
text_chunks: vec![],
outcome: Outcome::Succeed(vec![]),
};
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
});
assert!(factory.knows("acme"));
assert!(!ProviderFactory::standard().knows("acme"));
let app =
AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory);
// end snippet
Ok(())
}
@@ -0,0 +1,117 @@
//! The world every adoption guide block runs in, with no network and no real settings.
//!
//! The launcher has already set things up: one tool ("Tighten text"), one connection ("Tidy", either
//! on this computer or online with a saved key) and the person's choice of its model. An app then
//! builds a client that ships a scripted adapter for the "tidy" service. Guide blocks start from
//! here, so they can show only the lines a reader needs.
// Each guide test file uses a different part of this module.
#![allow(dead_code)]
use std::sync::Arc;
use craft_ai_client::{AiClient, ProviderFactory, ToolDesk};
use craft_ai_core::mock::{MockProvider, Outcome, Script};
use craft_ai_core::{
AppId, CapabilityId, CapabilitySpec, Connection, ConnectionId, InputRole, ModelId, ModelInfo,
Output, Payload, Provider, Request, Role, RoleId, SecretRef, SecretString,
};
use craft_ai_native::secrets::{MemoryStore, SecretStore};
use craft_ai_native::shared::SharedStore;
/// What a guide test returns, so a block can use `?` as an app would.
pub type TestResult = Result<(), Box<dyn std::error::Error>>;
/// The launcher's side of the story, already done.
pub struct Scene {
/// Keeps the temporary folder alive until the test ends.
pub _dir: tempfile::TempDir,
pub shared: SharedStore,
pub keys: Arc<MemoryStore>,
pub role: Role,
pub model: ModelInfo,
pub launcher: ToolDesk,
}
impl Scene {
/// A temporary store with the tool declared and its model chosen. `online` makes the
/// connection a metered remote service with a saved key, so using it needs consent.
pub fn new(online: bool) -> Result<Self, Box<dyn std::error::Error>> {
let dir = tempfile::tempdir()?;
let shared = SharedStore::new(dir.path().join("connections.json"));
let keys = Arc::new(MemoryStore::new());
let role = Role {
id: RoleId::new("tighten-text")?,
label: "Tighten text".into(),
capability: CapabilityId::parse("text.complete")?,
inputs: vec![InputRole::Prompt],
};
let model = ModelInfo::new(ModelId::new("tidy-1")?, "Tidy 1").with(
CapabilitySpec::new(role.capability.clone())
.accepting(InputRole::Prompt, 1)
.requiring(InputRole::Prompt),
);
let connection = if online {
keys.set(
&SecretRef::new("tidy-key")?,
&SecretString::new("not-a-real-key"),
)?;
serde_json::json!({
"id": "tidy", "label": "Tidy (online)", "provider": "tidy",
"endpoint": "https://tidy.example.com/v1", "locality": "remote",
"auth": {"kind": "api_key", "secret": "tidy-key"},
"billing": "metered", "models": [model.clone()],
})
} else {
serde_json::json!({
"id": "tidy", "label": "Tidy (on this computer)", "provider": "tidy",
"locality": "local", "auth": {"kind": "none"},
"billing": "free", "models": [model.clone()],
})
};
let connection: Connection = serde_json::from_value(connection)?;
shared.update(|store| store.connections.push(connection))?;
let launcher = ToolDesk::new(
AiClient::new(AppId::new("launcher")?, shared.clone(), keys.clone()),
shared.clone(),
None,
);
launcher.register_tool(role.clone())?;
launcher.choose(&role, &ConnectionId::new("tidy")?, &model.id)?;
Ok(Self {
_dir: dir,
shared,
keys,
role,
model,
launcher,
})
}
}
/// The app's client. This app ships an adapter for the "tidy" service (a scripted stand-in that
/// answers "Shorter."), so the launcher's choice is ready here with no setup.
pub fn scripted_client(
shared: &SharedStore,
keys: &Arc<MemoryStore>,
) -> Result<AiClient, Box<dyn std::error::Error>> {
let mut factory = ProviderFactory::standard();
factory.register("tidy", |connection, _key| {
let script = Script {
running_polls: 1,
text_chunks: vec![],
outcome: Outcome::Succeed(vec![Output {
payload: Payload::text("Shorter."),
label: None,
}]),
};
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
});
Ok(AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory))
}
/// A request for the tool: one prompt.
pub fn tighten_request(role: &Role) -> Request {
Request::new(role.capability.clone())
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
}
+2
View File
@@ -1,5 +1,7 @@
# Add a service or a tool, and use it from an app
**The full guides are in [docs/adopting](adopting/README.md):** use a tool in an app, declare a new tool, bring a service or model, a checklist and troubleshooting. This page is the short version.
For someone building on this layer: you ship a service, a kind of tool, or an app that uses them.
You should not need to edit this repository or anyone else's app. This page is the path; the
runnable version of every step is `crates/ai-client/tests/third_party_adoption.rs`, which CI runs,
+117
View File
@@ -0,0 +1,117 @@
# Adopting the Craft AI layer
You want an AI tool in your app, and you want it to work the way the rest of the suite does:
a person sets up a model once, and your tool uses it.
This section is for **developers**. It covers adding a tool to an app, declaring a new kind of
tool, and bringing a service or model that can run one. You do not need to edit this repository,
the launcher or anyone else's app.
## The idea in one minute
```mermaid
flowchart LR
P[Person] -->|sets up once| L[Launcher or your Preferences]
L -->|writes| S[(Shared store<br/>connections, models,<br/>choices)]
A1[Your app] -->|reads| S
A2[Another app] -->|reads| S
A1 -->|status, plan, start| C[AiClient]
C -->|your adapter| M[Model or service]
```
Five things, in the order you meet them:
| Word | Meaning |
|---|---|
| **Tool** (a `Role`) | A job your app offers, such as "Select subject". It has an id, one capability and the inputs it sends. |
| **Connection** | A place that can run models: a hosted service, a server on this computer, or the built-in engine. It holds the models it offers. Keys live in the keychain, not here. |
| **Binding** | The person's choice of connection and model for one tool. A suite default, with an optional override per app. |
| **Adapter** (a `Provider`) | The code in your app that talks to a connection. An app that has no adapter for a connection says so plainly. |
| **Job** | One run. You start it, poll it, and can cancel it. |
Your app owns the interface and the result. The shared layer owns everything between.
| The shared layer does | Your app does |
|---|---|
| Stores connections, models, choices, consent and history | Draws the tool: a prompt bar, a button, a click on the image |
| Reads keys, only at the moment a job starts | Builds the request from its own document |
| Checks the model can take your inputs | Shows the disclosure and asks for consent |
| Refuses to send data without consent | Applies the result, with its own undo |
| Runs and tracks the job | Offers cancel, progress and readable errors |
## Pick your path
| I want to... | Read | Time |
|---|---|---|
| Add a tool that already exists (Select subject, Remove objects) to my app | [Use AI tools in your app](use-in-your-app.md) | 30 minutes to a first run |
| Offer a kind of tool the suite does not have yet | [Declare a new tool](declare-a-tool.md) | 15 minutes |
| Make my service, API, server or model runnable by the tools | [Bring a service or model](bring-a-service.md) | 1 hour for a `Provider` |
| Check I did it properly before I ship | [Checklist](checklist.md) | 10 minutes |
| Work out why it says "Unavailable" | [Troubleshooting](troubleshooting.md) | 5 minutes |
Most people need the first guide and the checklist.
## See it run first
Every page here is checked by the compiler. The whole story, with two independent apps and a
third one that cannot run the service, is one test:
```sh
cargo test -p craft-ai-client --test third_party_adoption -j 8
cargo test -p craft-ai-client --doc -j 8
cargo test -p craft-ai-client --test adopting_app_guide --test adopting_tool_guides --test adopting_drift -j 8
cargo run -p craft-ai-client --example custom_tool -j 8
```
None of them touches the network or your real connections. They use a temporary folder and a
scripted stand-in service. If a page and the code disagree, the code is right and the page has
a bug. Please fix the page.
## What you get, and what you do not
You get: one place where keys, models and choices live; consent that is scoped to your app, the
connection, the destination and the kind of data; a job model that works for a two-second edit
and a two-minute video; a log; and shared checks that any adapter can run.
You do not get, today:
- A background service. The runtime is **in the app's own process**. Your app links
`craft-ai-client` and runs its adapters itself. A shared daemon is a separate discussion.
- Dynamic code loading. A tool is a declaration, and an adapter is code you compile into your app.
- Automatic model installation. Setting up a model is the launcher's Set up AI window, or your
own screen built from the shared pieces, and it always asks first.
- A stable API. The contract is accepted in `craft-libs`, but this workspace still carries
newer additions that are under review ([CORE-SYNC](../CORE-SYNC.md)). Pin a revision.
Read [STATUS](../STATUS.md) before you rely on a provider. It lists what ran against a scripted
server, what ran for real on one machine, and what has not run at all. Your own docs should use the
same levels, and never claim more.
## Ground rules
These come from the people who use the suite. The checks enforce most of them.
1. **One model choice, everywhere.** Never hard-code a model or an address. Read the binding.
2. **No silent substitution.** If the chosen model cannot run, say so. Do not run a different one.
3. **Nothing leaves the computer without a yes.** The layer enforces consent. Do not route around it.
4. **Keys never travel.** Not through environment variables you set, command lines, logs or errors.
5. **Plain words.** Everything a person reads avoids engine names, hardware words and developer terms.
6. **Every app works with no AI.** An unset tool is greyed out with a reason, never broken.
7. **Say what you ran.** Configured, verified and executable are three different claims.
## Where things live
| Folder | What is in it |
|---|---|
| `crates/ai-core` | The data model and the `Provider` trait. No I/O. Builds for wasm. |
| `crates/ai-client` | `AiClient`, `ToolDesk` and `ProviderFactory`. What an app holds. |
| `crates/ai-features` | The catalogue of tools and how each can be set up. |
| `crates/ai-native` | The shared store file, key stores, consent and history files. |
| `crates/ai-egui` | Optional theme-neutral widgets. |
| `docs/` | These guides, the [layout](../LAYOUT.md), the [platform contract](../PLATFORM-CONTRACT.md) and [custom connections and sources](../custom-connections-and-sources.md). |
## Getting help and giving back
Open an issue on `storytold/craft-ai-integrations`. Say which guide you followed, which step was
unclear and what you expected. A step that needed something the page did not say is a bug in the
page. Small fixes to these pages are welcome as pull requests. See [CONTRIBUTING](../../CONTRIBUTING.md).
+267
View File
@@ -0,0 +1,267 @@
# Bring a service or a model
Use this page when you want something to **run** a tool: a hosted API, a server on the person's
computer, or a model file. Pick the shortest route that works.
| You have | Write | Rust? |
|---|---|---|
| A hosted API with plain request and reply (or submit and poll) | One settings file for the declarative adapter | No |
| A hosted or local API with another shape | A `Provider`, registered by name | Yes |
| A model file that should run inside the app, on this computer | A `ModelRunner` for a built-in engine | Yes |
| A ComfyUI workflow | Import it in Set up AI (see the [status page](../STATUS.md) for which tools this covers) | No |
Every Rust block here is a real test (`cargo test -p craft-ai-client --test adopting_tool_guides`), and
`--test adopting_drift` fails if a block differs from the tested code.
You never edit an app. An app that does not ship your adapter reports your service as
unavailable, in plain words, and asks for no consent and reads no key.
## Route 1: a settings file
If the API is "send JSON, get JSON" or "submit, then poll", describe it as data. No code runs
from the file. Read [the design](../design/declarative-adapter.md) for the format and its limits.
It does not cover signed requests, token refresh, file uploads or streaming.
## Route 2: write a `Provider`
A `Provider` has five methods:
| Method | Must |
|---|---|
| `models()` | List the models, each with the capabilities and inputs it really supports. This is also the "test connection" call. |
| `estimate(model, request)` | Optional. A price estimate when you can give one. |
| `submit(model, request)` | Check the request against the model's spec and refuse **before sending anything** with `Unsupported` or `InvalidRequest`. Then start the job and return its id. |
| `poll(job)` | Return the state and only the events that are new since the last poll. A finished state must stay the same. |
| `cancel(job)` | Stop the job. If you cannot stop an unfinished job, return `Unsupported`. Calling it twice, or on a finished job, is not an error. An unknown job is. |
The trait is synchronous on purpose. Do the slow work on your own thread and let `poll` read the
state. Here is a complete one, small enough to read in a minute. It "shouts" a prompt back:
<!-- snippet: shouter_provider -->
```rust
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use craft_ai_core::payload::Limits;
use craft_ai_core::{
AiError, CapabilityId, CapabilitySpec, ErrorKind, Event, InputRole, JobId, JobSnapshot,
JobState, ModelId, ModelInfo, Output, Payload, Provider, Request,
};
#[derive(Clone, Copy, PartialEq)]
enum Phase {
Queued,
Running,
Done,
Cancelled,
}
struct Job {
phase: Phase,
answer: String,
}
struct Shouter {
model: ModelInfo,
jobs: Mutex<HashMap<String, Job>>,
next: AtomicU32,
}
impl Shouter {
fn new() -> Result<Self, AiError> {
let capability = CapabilityId::parse("text.complete").map_err(bad)?;
let spec = CapabilitySpec::new(capability)
.accepting(InputRole::Prompt, 1)
.requiring(InputRole::Prompt);
let id = ModelId::new("shout-1").map_err(bad)?;
Ok(Self {
model: ModelInfo::new(id, "Shout 1").with(spec),
jobs: Mutex::new(HashMap::new()),
next: AtomicU32::new(1),
})
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, HashMap<String, Job>>, AiError> {
self.jobs
.lock()
.map_err(|_| AiError::new(ErrorKind::Internal, "the job list is unavailable"))
}
}
fn bad(e: impl std::fmt::Display) -> AiError {
AiError::new(ErrorKind::Internal, e.to_string())
}
fn unknown() -> AiError {
AiError::invalid("that job does not exist")
}
impl Provider for Shouter {
fn models(&self) -> Result<Vec<ModelInfo>, AiError> {
Ok(vec![self.model.clone()])
}
fn submit(&self, model: &ModelId, request: &Request) -> Result<JobId, AiError> {
// Refuse first. Nothing has been sent anywhere yet.
if *model != self.model.id {
return Err(AiError::unsupported("this service has no such model"));
}
let spec = self.model.spec_for(&request.capability).ok_or_else(|| {
AiError::unsupported(format!("this model cannot do {}", request.capability))
})?;
request.validate(spec, &Limits::default())?;
let answer = match request.inputs.first().map(|i| &i.payload) {
Some(Payload::Text { text }) => text.to_uppercase(),
_ => return Err(AiError::invalid("a text prompt is needed")),
};
let name = format!("job-{}", self.next.fetch_add(1, Ordering::SeqCst));
self.lock()?.insert(
name.clone(),
Job {
phase: Phase::Queued,
answer,
},
);
JobId::new(&name).map_err(bad)
}
fn poll(&self, job: &JobId) -> Result<JobSnapshot, AiError> {
let mut jobs = self.lock()?;
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
Ok(match entry.phase {
Phase::Queued => {
entry.phase = Phase::Running;
JobSnapshot::new(JobState::Running { percent: Some(50) })
}
Phase::Running => {
entry.phase = Phase::Done;
let mut done = JobSnapshot::new(JobState::Succeeded);
done.events.push(Event::Output(Output {
payload: Payload::text(entry.answer.clone()),
label: None,
}));
done // the answer is delivered once
}
Phase::Done => JobSnapshot::new(JobState::Succeeded),
Phase::Cancelled => JobSnapshot::new(JobState::Cancelled),
})
}
fn cancel(&self, job: &JobId) -> Result<(), AiError> {
let mut jobs = self.lock()?;
let entry = jobs.get_mut(job.as_str()).ok_or_else(unknown)?;
if matches!(entry.phase, Phase::Queued | Phase::Running) {
entry.phase = Phase::Cancelled;
}
Ok(())
}
}
// Prove it with the checks every provider must pass.
use craft_ai_core::conformance::{run_cancel, run_lifecycle, run_rejects, run_unknown_job};
let provider = Shouter::new()?;
let model = provider.model.id.clone();
let capability = CapabilityId::parse("text.complete")?;
let good = Request::new(capability.clone()).with_input(InputRole::Prompt, Payload::text("hi"));
let no_prompt = Request::new(capability);
run_lifecycle(&provider, &model, &good)?;
run_cancel(&provider, &model, &good)?;
run_unknown_job(&provider)?;
run_rejects(&provider, &model, &no_prompt, ErrorKind::InvalidRequest)?;
```
Rules your provider must keep (the checks above test most of them):
- **Refuse before sending.** A request the model cannot take is rejected in `submit`, with nothing
on the wire.
- **Keep time moving forward.** A job starts `Queued` and may become `Running` before it ends. It
may also end straight from `Queued`. Progress never goes down. A finished job stays finished.
- **Deliver events once.** Each result appears in exactly one poll.
- **Never put a key in an error, a log line or an event.** Use `AiError::redacted(kind, message,
&[&secret])` when a reply might echo the key back.
- **Use the platform's HTTP setting.** Build agents through `craft-ai-http`. A test in this
repository fails if a crate builds one without it.
- **Say which kind of failure it is.** `Auth`, `RateLimited` (with `with_retry_after`), `Quota`,
`Network` and `Provider` each lead to a different thing on screen.
### Register it
The name you register is the `provider` field of a connection. Registering is the whole of
adding an adapter to an app:
<!-- snippet: register_adapter -->
```rust
use std::sync::Arc;
use craft_ai_client::{AiClient, ProviderFactory};
use craft_ai_core::mock::{MockProvider, Outcome, Script};
use craft_ai_core::{AppId, Provider};
let mut factory = ProviderFactory::standard();
factory.register("acme", |connection, key| {
// `key` is already read from the key store. The adapter never touches a keychain.
// A real adapter sends `key` with each request. This stand-in has nowhere to send it.
let _ = key;
let script = Script {
running_polls: 1,
text_chunks: vec![],
outcome: Outcome::Succeed(vec![]),
};
Ok(Arc::new(MockProvider::new(connection.models.clone(), script)) as Arc<dyn Provider>)
});
assert!(factory.knows("acme"));
assert!(!ProviderFactory::standard().knows("acme"));
let app =
AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone()).with_factory(factory);
```
### Describe the connection
A person adds the connection in Set up AI. By hand, or from code, it is data:
```json
{
"id": "acme",
"label": "Acme",
"provider": "acme",
"endpoint": "https://api.acme.example/v1",
"locality": "remote",
"auth": { "kind": "api_key", "secret": "acme-key" },
"billing": "metered",
"models": [ { "id": "acme-vision-1", "label": "Acme Vision" } ]
}
```
- `locality: "remote"` means anything sent needs the person's agreement first. Use `"local"`
only for something that stays on this computer.
- `auth.secret` names a key in the key store. It is a name, not the key. Keys never go in this
file, in an environment variable you set for the person, or on a command line.
- Remote endpoints must use HTTPS. Plain HTTP is accepted only for a loopback address.
## Route 3: a model that runs inside the app
For a model file that should run on this computer with nothing to install, write a
`ModelRunner` for a built-in engine. The job queue, threads, Cancel, progress, loading the
weights once and turning a crash into a failed job are already done. See
[Add a model to a built-in engine](../ADD-AN-ENGINE.md). Its code is compiled in that crate's tests,
like this page.
If the person's model should come from somewhere else (their own ComfyUI, a different engine),
that is another connection for the same tool. No app changes.
## Be honest about what you ran
A scripted test shows your adapter follows the contract. It does not show the real service
works. When you publish an adapter, say which of these you did:
- ran the conformance checks against your adapter;
- ran it against a scripted server on this computer;
- ran it against the real service, once, with a stated model and input;
- ran it from inside an app and checked what landed in the document.
Do not describe one as another. The repository's [status page](../STATUS.md) uses these levels.
Next: the [checklist](checklist.md).
+97
View File
@@ -0,0 +1,97 @@
# Checklist
Use the section that matches what you built. Tick a box only when you ran the thing and saw it.
"It compiles" and "the test passes" are not the same as "the tool works in the app".
## An app that uses a tool
### Design
- [ ] The tool's role comes from the catalogue (`Feature::role()`), or is a declared tool whose id
was agreed with the other maintainers.
- [ ] The app id is the lower-case name the launcher uses. It never changes.
- [ ] The app starts and every other feature works with no connection at all.
### Behaviour
- [ ] `status` is called each time the tool is about to be used, not once at start-up.
- [ ] `Unavailable` greys the tool out and shows the reason as given. It is not drawn as an error.
- [ ] The missing-model popup (`ToolDesk::missing_model`) opens from a greyed-out tool, and
choosing a model there goes through `ToolDesk::choose`.
- [ ] `plan` runs before any consent prompt. Nobody is asked to agree to a request the app cannot run.
- [ ] The consent prompt shows `plan.disclosure()` and offers `ConsentTerm::ALL`.
- [ ] "This time" leaves nothing on disk. Longer terms are saved only when `term.remembers()`.
- [ ] `start` and `poll` run off the interface thread.
- [ ] Progress is shown. Cancel is offered while the job is queued and while it runs. If `cancel`
returns an error (`Unsupported` when targeted cancel is off for a ComfyUI server), the job
keeps running: the app waits for any end state, not only `Cancelled`, discards the result,
and releases the input once the job has ended.
- [ ] Every `ErrorKind` has a path on screen (see the table in the app guide). `Cancelled` shows nothing.
- [ ] The result is applied through the app's own undo, and survives save and reopen.
### Safety
- [ ] The app never reads, stores, prints or logs a key.
- [ ] No model id, provider name or address is hard-coded. A broken binding is an error, never
a reason to use a default.
- [ ] No text a person can read contains engine names or hardware words.
- [ ] `keep_prompts_in_history` is off, unless the person turned it on.
- [ ] Tests set `CRAFT_AI_DIR` (or use a temporary store). None of them touches real settings.
### Tests to write
Each of these catches a real class of bug. Use a scripted adapter and a temporary store.
- [ ] **Unbound:** no model chosen, so `status` is `Unavailable`, and nothing is sent.
- [ ] **Bound:** the suite default is used, and the adapter receives the right request.
- [ ] **A second app id** reads the same default without any setup.
- [ ] **An app override** beats the default, and `clear_binding` restores the default.
- [ ] **No adapter:** the app that does not ship the adapter is `Unavailable`, `plan` is
`Unsupported`, no key is read and the other app's adapter saw nothing.
- [ ] **A remote connection** gives `ConsentRequired` and builds no adapter until agreed.
- [ ] **A removed connection** is an error. It is not "unbound".
- [ ] **Cancel** ends in `Cancelled` and does not disturb another job.
- [ ] **Old store file:** a file from before custom tools existed still loads.
Watch each behavioural test fail against a stub first, so you know it can.
### Proof
- [ ] A real model was run through **your own tool** and the stored result checked: its size,
its content, and that it is still there after save and reopen.
- [ ] You say which of these you did: scripted test, local server, real service, inside the app.
Never describe one as another.
## A new tool
- [ ] The capability is the narrowest honest one (`image.describe`, not `image.analyse`).
- [ ] `inputs` lists exactly what the tool sends, and no more.
- [ ] The id is registered (`register_tool`) if more than one app will use it.
- [ ] Two apps that declare the same id agree on its capability and inputs. A mismatch is refused.
- [ ] At least one model was chosen for it with `ToolDesk::choose`, and `choose` refused a model that
could not do the job.
- [ ] The result shape is written down (text, media type, or JSON) for the next person.
- [ ] If it should be easy to set up, a catalogue entry has size, SHA-256, source and licence for
every file, and a statement that licences are disclosed and not checked.
## A service or a model
- [ ] `models()` lists only what the service really does. Capabilities and inputs are not guessed
from a model's name.
- [ ] A request the model cannot take is refused in `submit`, before anything is sent.
- [ ] The conformance checks pass: `run_lifecycle`, `run_cancel`, `run_unknown_job`, `run_rejects`.
- [ ] Remote means `locality: remote`, and the endpoint is HTTPS (or loopback).
- [ ] The key arrives through the connection's secret name. It is never in an error, a log or an event.
- [ ] HTTP agents come from `craft-ai-http`.
- [ ] Errors use the right `ErrorKind`, and `RateLimited` carries `retry_after_secs` when known.
- [ ] You tested a failure: a wrong key, a full quota, a dropped connection, a cancelled job.
- [ ] The docs for your adapter say what ran: conformance, scripted server, the real service, or
inside an app.
## Before you open a pull request here
- [ ] Targeted cargo only, one process at a time: `cargo test -p <crate> -j 8`. No workspace builds.
- [ ] `cargo fmt --all --check` and `cargo clippy -p <crate> --all-targets --locked -j 8 -- -D warnings`.
- [ ] No file over 500 lines. No `unwrap`, `expect` or `panic` in shipped code.
- [ ] No personal folder paths in anything you commit.
- [ ] The pull request says plainly what was run and what was not.
+180
View File
@@ -0,0 +1,180 @@
# Declare a new tool
Use this page when the suite has no tool for what you want. Examples: "describe a photograph",
"explain this CAD part", "clean up this audio". You declare the tool once. After that, any
model that can do the job can be chosen for it, and every app that declares the same tool
shares the person's choice.
You do not need to edit this repository, the launcher or anyone else's app.
Every Rust block here is a real test (`cargo test -p craft-ai-client --test adopting_tool_guides`).
`--test adopting_drift` fails if a block differs from the tested code.
## What a tool is
A tool is a **declaration, not code**. It is a `Role`:
| Field | What it is | Rules |
|---|---|---|
| `id` | The tool's name across the whole suite, for example `describe-photo`. | Letters, digits and `- _ . : /`. Up to 128 bytes. Agree it with the other maintainers. |
| `label` | What a person sees. | Plain words. Each app may translate it. |
| `capability` | The one thing a model must be able to do. | `<modality>.<task>`, such as `image.describe`. |
| `inputs` | What your tool sends. | Any of `prompt`, `system`, `source`, `mask`, `reference`, `context`. |
Two rules decide which models a person can choose for your tool:
1. The model must offer your **capability**.
2. The model must **accept every input you list**, and must **require none you cannot supply**.
So `inputs` is a promise about what your tool sends. List only what you really send. A model
that needs a mask is not offered for a tool that never sends one.
## Choose the capability
A capability id is `<modality>.<task>`. The set is open, so `cad.explain` is valid with no
library change. Before you invent one, look at the list the suite already shares, so models and apps agree
on spelling:
<!-- snippet: well_known -->
```rust
use craft_ai_core::capability::well_known;
let known: Vec<String> = well_known().iter().map(|c| c.to_string()).collect();
assert!(known.contains(&"image.inpaint".to_string()));
assert!(known.contains(&"image.segment".to_string()));
```
Modalities are `text`, `image`, `video`, `audio`, `document`, `tabular`, `vector` and `mesh`. A
new modality is allowed. Tasks are lower-case tokens with no dots.
Choose the narrowest honest capability. `image.describe` is better than `image.analyse`: a
person looking for a model wants to know it can do exactly this.
## Declare it and choose a model
You do this once, from any process that shares the connections file: the launcher, your app's
Preferences, or a setup script.
<!-- snippet: declare_and_choose -->
```rust
use craft_ai_core::{
CapabilityId, CapabilitySpec, ConnectionId, InputRole, ModelId, ModelInfo,
};
use craft_ai_core::{Role, RoleId};
// `launcher` is a `ToolDesk`, and `shared` is the connections file. See the tests for the scene.
// 1. The declaration.
let describe = Role {
id: RoleId::new("describe-photo")?,
label: "Describe photograph".into(),
capability: CapabilityId::parse("image.describe")?,
inputs: vec![InputRole::Source],
};
launcher.register_tool(describe.clone())?;
// 2. A model that can do it, offered by a connection the person already has.
let vision = ModelInfo::new(ModelId::new("vision-1")?, "Vision 1").with(
CapabilitySpec::new(describe.capability.clone())
.accepting(InputRole::Source, 1)
.requiring(InputRole::Source),
);
let service = serde_json::from_value(serde_json::json!({
"id": "vision-service", "label": "Vision (on this computer)", "provider": "vision",
"locality": "local", "auth": {"kind": "none"}, "billing": "free",
"models": [vision.clone()],
}))?;
shared.update(|store| store.connections.push(service))?;
// 3. The suite-wide choice. It is checked before anything is written.
launcher.choose(&describe, &ConnectionId::new("vision-service")?, &vision.id)?;
assert_eq!(launcher.custom_tools()?.len(), 2); // "tighten-text" from the scene, and this one
```
`register_tool` refuses a name that a built-in tool already uses and a name that is already
taken. `choose` refuses, and writes nothing, when the model cannot do the job.
Declaring is optional for a tool that only your app uses: any app can build a `Role` in code and
call `status` and `plan`. Register it when more than one app should share the choice, because
registering is what protects the contract (next section).
## An id is a contract
Once a tool is registered, its id means one capability with one set of inputs, everywhere.
An app may change the label. It may **not** change what the id means.
<!-- snippet: id_is_a_contract -->
```rust
use craft_ai_client::{AiClient, RoleStatus};
use craft_ai_core::{AppId, CapabilityId, InputRole, Role, RoleId};
let honest = Role {
id: RoleId::new("shorten")?,
label: "Shorten".into(),
capability: CapabilityId::parse("text.complete")?,
inputs: vec![InputRole::Prompt],
};
launcher.register_tool(honest.clone())?;
// Another app reuses the id for something else. It is refused, not quietly accepted.
let imposter = Role {
capability: CapabilityId::parse("image.generate")?,
..honest
};
let app = AiClient::new(AppId::new("wordcraft")?, shared.clone(), keys.clone());
match app.status(&imposter) {
RoleStatus::Unavailable { reason } => assert!(reason.contains("does not match")),
RoleStatus::Ready(_) => panic!("a changed contract must not resolve"),
}
```
The same check runs in `choices`, `picker`, `bind`, `plan` and `choose`. If you need a different
tool, use a different id.
## Send the right data
Build the request from the inputs you declared. Use `Payload::text` for words,
`Payload::inline` for bytes you hold, and `Payload::handle` for a file too big to copy.
<!-- snippet: describe_request -->
```rust
use craft_ai_core::{CapabilityId, InputRole, Payload, Request};
let request = Request::new(CapabilityId::parse("image.describe")?).with_input(
InputRole::Source,
Payload::inline("image/png", vec![0x89, b'P', b'N', b'G'])?,
);
assert_eq!(request.count(&InputRole::Source), 1);
```
Answers come back as `Output` events. Choose the shape once and write it down for the next
person: plain text for words, an image or audio payload for media, and `application/json`
for structured results.
If many models and many apps will do the same job, give it a fixed contract like selection has
(`craft_ai_core::segment`): one request shape, one result shape, and checks on everything read
back. That way a click, a box and "nothing there" mean the same on every route.
## Let people set it up
A declared tool shows up in the launcher's tool list and in the shared picker. To make it
easy to run on a computer, add a catalogue entry in `craft-ai-features`: model files with size,
SHA-256, source and licence, and the engines that can run them. That is a pull request to this
repository. It is not needed for the tool to work. A person can always add a connection and
model by hand.
Licences are **disclosure, not clearance**. List every part a recipe runs, with the terms its
publisher states and a link. Say plainly that nothing was checked and that it is not legal advice.
## Remove it
`ToolDesk::remove_tool(&id)` deletes the declaration and every suite and per-app choice that
named it. Built-in tools cannot be removed.
## What a declaration does not do
- It does not install code. A tool is only runnable when some app ships an adapter for the
chosen connection's provider, and the app that does not says so plainly.
- It does not add a menu, a button or a panel. The interface is the app's.
- It does not pick a model for the person. A model is chosen only when they agree.
Next: [Bring a service or model](bring-a-service.md) to make something that can run it.
+59
View File
@@ -0,0 +1,59 @@
# Troubleshooting
Find the words on your screen. Each row says what the layer meant and what to do.
## The tool is greyed out
| What `status` says | What happened | Fix |
|---|---|---|
| `no model is chosen for 'select-subject'` | Nothing is bound for that tool, in the suite or for this app. | Open the popup (`ToolDesk::missing_model`) or Set up AI, and choose a model. This is normal on a fresh install. |
| `This app can't run the chosen model yet. Choose another in Set up AI.` | The chosen connection's provider name has no adapter registered in this app's `ProviderFactory`. | Register the adapter, or choose a model this app can run. Check the provider name matches exactly (it is not case sensitive). |
| `connection <id> is switched off` | The connection exists but is disabled. | Switch it on in Set up AI. |
| `connection <id> no longer exists` | The binding points at a deleted connection. | Choose again. `ToolDesk::clear` removes a stale default. |
| `connection <id> does not offer model <m>` | The model was removed from the connection. | Choose another model. |
| `model <m> cannot do <capability>` | The bound model does not offer the tool's capability. | Choose a model that does. |
| `model <m> does not accept a '<input>' input` | Your tool sends an input the model cannot take. | Choose another model, or stop sending that input if the tool does not need it. |
| `tool <id> does not match its shared definition` | Another app registered this id with a different capability or inputs. | Use the registered meaning, or pick a new id. |
| `no key is saved for connection "<label>"; add it in the ArtCraft Launcher` | The connection needs a key and the key store has none. | Add the key in the launcher or your Preferences. |
| `connections file: ...` or `no place to keep the connections file on this system` | The shared store could not be read, or the OS gave no config folder. | Check the folder, or set `CRAFT_AI_DIR`. |
The "model cannot take your input" cases are a feature. They keep a model from being offered for a
tool it cannot serve. Do not work around them by sending less than the tool needs.
## `plan` or `start` fails
| Error | Cause | Fix |
|---|---|---|
| `ConsentRequired`: `sending to <host> needs your agreement first` | The connection is remote and the ledger does not cover this app, destination, capability and kind of data. | Show the disclosure, call `ledger.agree(...)`, and start with the ledger it returns. |
| Consent asked again and again | The term was "this time", the term lapsed, the ledger was not saved, or the destination or capability changed. | Save the ledger when `term.remembers()`. Keep it in `client.consent_file()`. |
| `Unsupported`: `this model does not accept a '<input>' input` | The request carries an input the model's spec lacks. | Send only declared inputs. |
| `Unsupported`: `this model accepts at most N '<input>' input(s)` | Too many of one input. | Send fewer, or choose another model. |
| `InvalidRequest`: `a '<input>' input is required` | A required input is missing. | Send it. |
| `InvalidRequest`: `this request is for <a>, but "<tool>" needs <b>` | The request's capability differs from the tool's. | Build the request from `role.capability`. |
| `Unsupported`: `this model returns at most N output(s)` | `with_outputs` asked for too many. | Ask for fewer. |
| `Unsupported`: `this model cannot stream` | `streaming()` on a model that does not stream. | Remove it. |
| `InvalidRequest` on a payload | A payload is too big (32 MiB inline by default) or has a bad media type. | Use `Payload::handle` for large data. Media types are lower-case `type/subtype`. |
## The job misbehaves
| Symptom | Cause | Fix |
|---|---|---|
| Events seem to vanish | Each poll returns only new events. | Collect them in every loop. Do not poll just to read the state. |
| `the job took too long` | `Job::wait` hit its timeout. It tried to cancel the job, which may not work (see "Cancel does nothing"). | Use your own polling and a longer limit, or a different model. |
| The interface freezes on Start | `start` ran on the interface thread. | Move `start` and `poll` to a worker. |
| Cancel does nothing | Targeted cancel is off by default for some ComfyUI servers, because an older server may treat it as a global interrupt. | Expect the job to finish, and discard its result. |
| The result is empty but the job succeeded | The tool has a fixed result shape (selection does) and no match is a valid answer. | Read it with the contract: `segment::read_found` says whether anything matched. |
| `Auth` after the key is saved | The wrong key name, or the environment variable shadows the keychain. | Variables named `CRAFT_AI_SECRET_...` are read first. Unset the one in the way. |
| `RateLimited` | The service asked you to slow down. | Wait `retry_after_secs` when present. |
## My tests
| Symptom | Cause | Fix |
|---|---|---|
| A test changes my real settings | The test used `AiClient::standard`. | Use `AiClient::new` with a temporary `SharedStore`, or set `CRAFT_AI_DIR` for that test only. |
| `status` is `Ready` but the real tool does nothing | Ready means the route resolves. It does not mean a model ran. | Run the model through your tool and check the result in the document. |
| Two apps in a test disagree | Each client has its own `ProviderFactory`. An app without the adapter is `Unavailable`. | That is the point. Register the adapter in each client that should run it. |
| `register_tool` says the name is in use | A built-in tool, or an earlier tool, has that id. | Choose another id, or use the existing tool. |
If nothing here matches, run `client.status(&role)` and read the reason first. It is written to be
the answer.
+349
View File
@@ -0,0 +1,349 @@
# Use AI tools in your app
This guide takes an app from "no AI" to "a tool that runs, asks first when it must, can be
cancelled, and fails in words a person can read". You write the tool's interface. The shared
layer does the rest: connections, keys, which model does what, consent, jobs and the log.
Every Rust block on this page is a real test. `cargo test -p craft-ai-client --test adopting_app_guide`
runs each one against a scripted stand-in service, and `--test adopting_drift` fails if a block here
differs from the tested code. Nothing touches the network or your real connections.
In the examples, `client` is the client from step 2 and `role` is the tool from step 3. A block
that needs more says so.
**You need:** Rust 1.90 or newer, and a native (desktop) app. `craft-ai-core` also builds for
wasm, but the client, the stores and the adapters do not.
## 1. Add the dependency
The crates are not on crates.io. Pin a revision of `main` and never use a path dependency in
anything you ship:
```toml
[dependencies]
craft-ai-client = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<a commit on main>" }
craft-ai-core = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<same commit>" }
craft-ai-features = { git = "https://github.com/storytold/craft-ai-integrations", rev = "<same commit>" }
```
Use one revision for all of them. `craft-ai-core` is also in `storytold/craft-libs`, and the local
copy here is ahead of it (see [CORE-SYNC](../CORE-SYNC.md)), so do not mix the two.
## 2. Make one client
One `AiClient` per app. It knows your app's id, the shared connections file, the key stores and
the adapters your app ships.
<!-- snippet: standard_client -->
```rust
use craft_ai_client::AiClient;
use craft_ai_core::AppId;
// Reads the shared connections file the launcher writes, and keys from the environment
// and then the OS keychain.
let client = AiClient::standard(AppId::new("wordcraft")?)?;
```
Pick your app id once and keep it. It is how consent is scoped and how a person gives your app
its own choice of model. Use the lower-case name that the launcher uses.
Every app works with no connection at all. Your app must start, and its other features must work,
when nobody has set up AI.
`CRAFT_AI_DIR` moves the whole folder (connections, consent, history). Point it at a temporary
folder in your own tests so they never read or change a real person's settings.
## 3. Say which tool you are offering
A tool is a `Role`: an id, a label, one capability, and the inputs the tool sends. The
capability and the inputs decide which models can ever be chosen for it.
For a tool the suite already knows, take the role from the catalogue. Then you inherit the
person's choice and the setup recipes for free:
<!-- snippet: catalogue_role -->
```rust
use craft_ai_features::builtin;
let select_subject = builtin()
.into_iter()
.find(|feature| feature.id == "select-subject")
.and_then(|feature| feature.role())
.ok_or("the catalogue has no such tool")?;
assert_eq!(select_subject.capability.as_str(), "image.segment");
```
For a tool nobody has declared yet, write the `Role` yourself. That is the subject of
[Declare a new tool](declare-a-tool.md). Do not invent an id for a tool the catalogue already has.
## 4. Ask `status`, and draw what it says
`status` answers one question: can this tool run right now, in this app? It reads no key and
sends nothing.
<!-- snippet: status -->
```rust
use craft_ai_client::RoleStatus;
match client.status(&role) {
RoleStatus::Ready(ready) => {
// Enable the tool. You can show which model will do the work.
assert_eq!(ready.model_label, "Tidy 1");
}
RoleStatus::Unavailable { reason } => {
// Grey the tool out and show `reason` exactly as it is.
println!("{reason}");
}
}
```
Draw the three states the same way in every place:
| What you see | What to show |
|---|---|
| `Ready` | The tool, switched on. Optionally the model and whether it is free, metered, local or online. |
| `Unavailable`, nothing chosen | The tool, switched off, with the reason and a way to fix it (see step 9). Never call this an error. |
| `Unavailable`, anything else | The same. The reason names the problem: a missing key, a switched-off connection, a model that cannot take your inputs, or "This app can't run the chosen model yet". |
`reason` is written for a person. Show it as it is. Do not add engine names, hardware words or
developer words to it, and do not translate it into something vaguer.
Call `status` again whenever the tool is about to be used, not once at start-up. A person can
change their choice in the launcher while your app is open.
## 5. Build the request
A `Request` names a capability and carries typed inputs. The type matters: a mask is not just
another picture.
<!-- snippet: request -->
```rust
use craft_ai_core::{InputRole, Payload, Request};
let request = Request::new(role.capability.clone())
.with_input(InputRole::Prompt, Payload::text("Tighten this paragraph."))
.with_param("tone", serde_json::json!("plain"));
```
- **Inputs** are `prompt`, `system`, `source`, `mask`, `reference` and `context`. Send only the
ones your `Role` lists. A model that cannot take them is never offered for your tool.
- **Payloads** are `Payload::text`, `Payload::inline(media_type, bytes)` (up to 32 MiB by
default) or `Payload::handle(uri, media_type, size)` for something too big to copy.
- **Params** are named values. The consent prompt shows their names, never their values.
- Some tools have a fixed shape that every model and every app agrees on. Selection is one:
use `craft_ai_core::segment` (`with_clicks`, `with_area`, `read_found`, `mask`) and never invent
your own parameters or result labels for it.
## 6. Plan
`plan` checks the request against the chosen model and builds the disclosure: what would leave
this computer, and where it would go. It sends nothing and reads no key.
<!-- snippet: plan -->
```rust
let plan = client.plan(&role, request)?;
println!("{} will use {}", role.label, plan.connection_label());
```
`plan` is where most mistakes show up, before anyone is asked anything:
| `ErrorKind` | Meaning | Do |
|---|---|---|
| `Unsupported` | This app cannot run the choice, or the model cannot take that input or that many of it. | Show the message. Offer step 9. |
| `InvalidRequest` | The request is wrong: a missing required input, an oversized payload. | This is your bug, or the person gave bad input. Fix or say so. |
## 7. Consent
A local model needs no consent. Anything that leaves the computer does, and the layer enforces
it. `start` refuses with `ConsentRequired` unless the ledger covers this app, this connection,
this destination, this capability and each kind of data in the plan.
<!-- snippet: consent -->
```rust
use craft_ai_core::{ConsentLedger, ConsentTerm, ErrorKind};
let plan = client.plan(&role, request)?;
let mut saved = ConsentLedger::default(); // in a real app: load it, see below
// Without consent the job does not start, and no key is read.
let refused = client.start(&plan, &saved).expect_err("must refuse");
assert_eq!(refused.kind, ErrorKind::ConsentRequired);
if plan.needs_consent(&saved) {
let d = plan.disclosure();
println!("Send {} item(s) to {}?", d.items.len(), d.destination);
// The person answers with one of ConsentTerm::ALL, shortest first.
let now = 1_800_000_000; // in a real app: the current unix time in seconds
let ledger_for_this_job = saved.agree(d, ConsentTerm::ThisTime, now);
assert!(client.start(&plan, &ledger_for_this_job).is_ok());
// "This time" is not remembered: `saved` is unchanged.
assert!(plan.needs_consent(&saved));
}
```
The rules for your prompt:
- Show `plan.disclosure()`: the destination, and what is sent (the kind of each input and its
size). Nothing else about the request needs to be shown.
- Offer the four terms in `ConsentTerm::ALL`: this time, one hour, one day, until turned off. The
words are yours, so you can translate them. `ConsentTerm::id()` is the stable name.
- Start the job with the ledger that `agree` returns. Save your ledger only when
`term.remembers()` is true. "This time" must leave nothing on disk.
- Keep the ledger in `client.consent_file()` (`load_or_empty`, `save`). It is per app, so a yes in
one app never covers another.
- Do not build your own "are you sure" for online services. A second prompt with different
wording is a bug in the person's eyes.
## 8. Start, watch, cancel
`start` checks consent again, reads the key (only now), builds the adapter and submits the
job. It returns a `Job` you poll.
<!-- snippet: job -->
```rust
use std::time::Duration;
use craft_ai_core::{ConsentLedger, Event, JobState};
let plan = client.plan(&role, request)?;
let job = client.start(&plan, &ConsentLedger::default())?;
let final_state = loop {
let snapshot = job.poll()?;
for event in &snapshot.events {
match event {
Event::TextDelta { text } => print!("{text}"),
Event::Output(output) => println!("result: {:?}", output.payload),
Event::Notice { message } => println!("note: {message}"),
Event::ToolCall { .. } => {}
}
}
if snapshot.state.is_terminal() {
break snapshot.state;
}
std::thread::sleep(Duration::from_millis(5)); // in an app: poll from your own timer
};
assert_eq!(final_state, JobState::Succeeded);
```
- **Keep it off the interface thread.** Adapters are synchronous. `start` can do real work
before it returns, such as reading a key, building the adapter or calling a service, so call it and `poll` from a
worker or your own job system. `Job::wait(interval, timeout)` exists for scripts and tests.
- **States:** `Queued`, `Running { percent }` (percent is optional), then one of `Succeeded`,
`Failed { error }` or `Cancelled`. A finished state never changes.
- **Events** arrive once. Each poll returns only what is new since the last poll, so collect them
as you go. Results are `Event::Output`.
- **Cancel** with `job.cancel()`. It can be called twice and on a finished job. A connection that
cannot stop an unfinished job returns `Unsupported`: ComfyUI does so unless the app turned on
`with_targeted_interrupt(true)`, because an older server may treat the interrupt as global. When
cancel succeeds, wait for the `Cancelled` state before you let go of any picture the job was
reading. When it does not, wait for any end state and discard the result.
- **Apply the result yourself.** The layer returns bytes or text. Writing it into a document,
with undo, is your app's job.
## 9. When the tool cannot run
There are two honest answers, and the layer gives you the data for both. You draw them in your
own theme.
**No model chosen, or the choice stopped working.** Ask for the missing-model popup data. It
holds the reason and a picker with the real ways to fix it:
<!-- snippet: missing_model -->
```rust
use craft_ai_client::{AiClient, ToolDesk};
use craft_ai_core::AppId;
let desk = ToolDesk::new(
AiClient::new(AppId::new("notes")?, shared.clone(), keys.clone()),
shared.clone(),
None,
);
let popup = desk.missing_model(&role, &craft_ai_features::builtin())?;
// `Some` means the tool cannot run in this app; `None` means it can.
assert!(popup.is_some()); // the "notes" app ships no adapter for "tidy"
```
Choosing a model in your popup goes through `ToolDesk::choose`, which sets the default for the
whole suite. Refusal is atomic: if the model cannot do the job, nothing is written.
**The person wants a different model only in your app.** `client.bind(&role, &connection, &model)`
sets an override for your app and `client.clear_binding(&role.id)` removes it. An override
beats the suite default. A broken override is an error. It never falls back to something else,
because that could send data somewhere the person did not choose.
Setting up a model (downloads, keys, a local server) belongs to the launcher's Set up AI window or
to your own Preferences, built from the same shared pieces. Do not build a second setup flow. If you
have no setup screen, your popup links to the launcher.
## 10. Errors, in one table
A failed job carries an `AiError { kind, message }`. The message is safe to show.
| `ErrorKind` | Show | Then |
|---|---|---|
| `ConsentRequired` | Your consent prompt | Retry with the agreed ledger. |
| `Auth` | The message (it names the connection) | Send the person to Set up AI to fix the key. |
| `RateLimited` | "Try again shortly" | Wait `retry_after_secs` if present, then retry. |
| `Quota` | The message | Do not retry. It needs money or time. |
| `Network` | The message | Retry is reasonable. `error.is_retryable()` says so. |
| `Provider` | The message | One retry at most. Keep the log line. |
| `Unsupported` | The message | Offer step 9. |
| `InvalidRequest` | The message | Your bug, or bad input. |
| `Cancelled` | Nothing | The person did this. Not an error. |
| `Internal` | A short apology and the message | File a bug. The layer reports these and never panics. |
## 11. History and privacy
`AiClient::standard` keeps a shared log of jobs: which app, which connection, what kind of data,
how long, what it cost. It does **not** keep the text of prompts unless you call
`keep_prompts_in_history(true)`. Leave that off. `client.history()` reads it for every app and
`clear_history()` empties it. `without_history()` stops logging.
## 12. Widgets, if you want them
If your app is egui and has no chrome of its own, `craft-ai-egui` has theme-neutral status,
picker, consent prompt, progress, connection panel and history widgets. Hosts with their own
look should draw their own views over the same data. The data and the rules are shared. The
look is yours.
## 13. Test your integration with no network
Build a world with no network: a temporary store, an in-memory key store, the launcher's choice
written with `ToolDesk::choose`, and a scripted adapter registered on a `ProviderFactory`. The
test file `crates/ai-client/tests/support/adopting_scene.rs` does exactly this, and every block on
this page runs on top of it.
<!-- snippet: two_apps -->
```rust
use craft_ai_client::{AiClient, RoleStatus};
use craft_ai_core::AppId;
// `client` ships an adapter for the chosen service, so the suite default is ready with no setup.
assert!(matches!(client.status(&role), RoleStatus::Ready(_)));
// Another app reads the same store but ships no adapter, so it is unavailable, and says so.
// `shared` and `keys` are the temporary store and key store this test built.
let second = AiClient::new(AppId::new("lightcraft")?, shared.clone(), keys.clone());
assert!(matches!(
second.status(&role),
RoleStatus::Unavailable { .. }
));
```
The tests an adopting app should have are in the [checklist](checklist.md). A Ready badge
proves a route resolves. It does not prove the tool works. Run the real model through your own
tool and check what lands in the document.
## What not to do
- Do not store, read or print a key. The layer reads it inside `start` and nowhere else.
- Do not hard-code a model id, a provider or an address. Read the choice.
- Do not fall back to a different model when the chosen one fails.
- Do not show engine or hardware words (Candle, Burn, CUDA, VRAM, wgpu) to a person.
- Do not call a tool "ready" because files are downloaded or a key is saved. It is ready when
`status` says so and a real run produced the right result.
- Do not write a toolbar, a prompt bar or a model list into a shared crate. Those are the app's.
Next: [Declare a new tool](declare-a-tool.md), [Bring a service or model](bring-a-service.md),
or go to the [checklist](checklist.md).