localharness 0.10.26

A Rust-native agent SDK for Gemini. Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
<div align="center">

# `localharness`

**A Rust-native agent SDK for Gemini.** Build production agents with
streaming text, custom tools, safety policies, and background triggers
— all from a single `cargo add`. Zero external binaries.

[![crates.io](https://img.shields.io/crates/v/localharness.svg?style=flat-square)](https://crates.io/crates/localharness)
[![docs.rs](https://img.shields.io/docsrs/localharness?style=flat-square)](https://docs.rs/localharness)
[![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/badge/MSRV-1.85-orange.svg?style=flat-square)](Cargo.toml)

</div>

```rust
use localharness::{Agent, GeminiAgentConfig};

#[tokio::main]
async fn main() -> localharness::Result<()> {
    let agent = Agent::start_gemini(
        GeminiAgentConfig::new(std::env::var("GEMINI_API_KEY").unwrap())
            .with_system_instructions("You are a concise code reviewer."),
    ).await?;

    let response = agent.chat("Review: fn add(a: i32, b: i32) -> i32 { a - b }").await?;
    println!("{}", response.text().await?);

    agent.shutdown().await?;
    Ok(())
}
```

> **Status:** 0.10.x · stable Rust-native runtime · 11/11 built-in
> tools · MCP bridge · context-window compaction · **wasm32 + browser
> IDE** · **on-chain identity** (EIP-2535 Diamond registry on Tempo
> Moderato; every claimed name is an ERC-721 NFT with an ERC-6551
> token-bound wallet).

**Try it in your browser:** [`localharness.xyz`](https://localharness.xyz/)
— pick a name, claim a subdomain. The same `Agent` loop you'd embed
in a CLI host runs in a browser tab at `<name>.localharness.xyz`,
backed by a wallet you control and an on-chain registry that proves
it's yours across devices.

---

## Contents

- [Install]#install
- [Concepts]#concepts`Agent`, `Conversation`, `Connection`
- [Examples]#examples — streaming, tools, hooks, policies, triggers, multimodal
- [Built-in tools]#built-in-tools
- [Architecture]#architecture
- [Run in the browser]#run-in-the-browser
- [Design notes]#design-notes-performance--safety
- [FAQ]#faq
- [License]#license

---

## Install

```toml
[dependencies]
localharness = "0.10"
tokio        = { version = "1", features = ["macros", "rt-multi-thread"] }
```

Cargo features (off by default):

- `wallet``localharness::{wallet, registry}` modules: secp256k1
  keypair + BIP-39 mnemonic + RLP encoding + a JSON-RPC client for
  the on-chain `LocalharnessRegistry` Diamond. Useful for CLI tools
  and back-end indexers that want to query the registry without
  spinning up the browser app.
- `browser-app` — the in-tab HTMX-style IDE (cdylib for wasm-pack).
  Transitively enables `wallet`.

```sh
export GEMINI_API_KEY="your_api_key_here"
```

No Python install, no Go binary, no harness process — `cargo build` and
you have an agent. Get an API key from [Google AI Studio][aistudio].

[aistudio]: https://aistudio.google.com/app/apikey

---

## Concepts

| Layer | Type | Use when |
|------:|------|----------|
| **1** | [`Agent`] | One-shot or short-running scripts. Batteries included. |
| **2** | [`Conversation`] / [`ChatResponse`] | Long-lived sessions, history introspection, custom turn shapes. |
| **3** | [`Connection`] | Embed the SDK in your own runtime, swap the transport. |

[`Agent`]: https://docs.rs/localharness/latest/localharness/struct.Agent.html
[`Conversation`]: https://docs.rs/localharness/latest/localharness/struct.Conversation.html
[`ChatResponse`]: https://docs.rs/localharness/latest/localharness/struct.ChatResponse.html
[`Connection`]: https://docs.rs/localharness/latest/localharness/trait.Connection.html

---

## Examples

<details><summary><b>Stream text tokens as they arrive</b></summary>

```rust
use futures_util::StreamExt;

let response = agent.chat("Write a haiku about Rust.").await?;
let mut tokens = response.text_stream();
while let Some(chunk) = tokens.next().await {
    print!("{}", chunk?);
}
```
</details>

<details><summary><b>Stream thoughts and tool calls separately</b></summary>

Every cursor (`text_stream`, `thoughts`, `tool_calls`) replays from chunk
zero and advances independently — safe to consume concurrently from
multiple tasks.

```rust
use futures_util::StreamExt;

let response = agent.chat("What time is it in Tokyo?").await?;

let thoughts = async {
    let mut t = response.thoughts();
    while let Some(text) = t.next().await { eprint!("{}", text?); }
    Ok::<_, localharness::Error>(())
};
let calls = async {
    let mut c = response.tool_calls();
    while let Some(call) = c.next().await { println!("→ {}", call?.name); }
    Ok::<_, localharness::Error>(())
};

let (a, b) = tokio::join!(thoughts, calls);
a?; b?;
```
</details>

<details><summary><b>Register a custom tool</b></summary>

```rust
use localharness::{allow_all, Agent, ClosureTool, GeminiAgentConfig};
use serde_json::json;

let weather = ClosureTool::new(
    "get_weather",
    "Return the weather for a city.",
    json!({ "type": "object", "properties": { "city": { "type": "string" } } }),
    |args, _ctx| async move {
        let city = args["city"].as_str().unwrap_or("?");
        Ok(json!({ "weather": format!("sunny in {city}") }))
    },
);

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key)
        .with_tool(weather)
        .with_policies(vec![allow_all()]),
).await?;
```
</details>

<details><summary><b>Use the built-in file tools with a workspace sandbox</b></summary>

```rust
use localharness::{Agent, CapabilitiesConfig, GeminiAgentConfig};

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key)
        .with_capabilities(CapabilitiesConfig::unrestricted())
        .with_workspace("/home/me/project"),
).await?;

let response = agent.chat("List the Rust files under src/ and show the first 50 lines of lib.rs.").await?;
println!("{}", response.text().await?);
```

`workspace_only(...)` policies are auto-installed when `with_workspace`
is set; every file tool's path is canonicalized and rejected if it
escapes the workspace.
</details>

<details><summary><b>Policies — deny-by-default, ask before dangerous calls</b></summary>

```rust
use localharness::{deny_all, Policy};
use std::sync::Arc;

let policies = vec![
    deny_all(),                                // start from nothing
    Policy::allow("view_file"),                // safe reads ok
    Policy::ask("run_command", Arc::new(|call| {
        // Pop your own UI. Return true to approve.
        eprintln!("approve `{}`? {:?}", call.name, call.args);
        true
    })),
];
```

Precedence: `specific deny ≻ specific ask ≻ specific allow ≻ wildcard
deny ≻ wildcard ask ≻ wildcard allow`. Matches the Python SDK rule.
</details>

<details><summary><b>Structured output</b></summary>

```rust
let schema = serde_json::json!({
    "type": "object",
    "properties": {
        "summary":  { "type": "string" },
        "severity": { "type": "string", "enum": ["low", "medium", "high"] }
    },
    "required": ["summary", "severity"]
});

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key)
        .with_response_schema(schema.to_string()),
).await?;

let response = agent.chat("Triage this bug report: ...").await?;
let _ = response.text().await?; // drain
let out = agent.conversation().last_structured_output().unwrap();
println!("{out}");
```

The model calls the built-in `finish(output)` tool when it's done; the
agent extracts `output` into `last_structured_output()`.
</details>

<details><summary><b>Background triggers</b></summary>

```rust
use std::time::Duration;
use localharness::every;

let watchdog = every(Duration::from_secs(60), "deploy_watch", |ctx| async move {
    ctx.send_when_idle("Check the deployment status.").await
});

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key).with_trigger(watchdog),
).await?;
```
</details>

<details><summary><b>Multimodal input (images, PDFs, audio, video)</b></summary>

```rust
use localharness::{Content, Media, Part};

let chart = Media::from_path("./diagram.png")?
    .with_description("system architecture diagram");

let spec = Media::from_path("./spec.pdf")?;

let prompt: Content = vec![
    Part::from("List three vulnerabilities, citing the diagram and spec."),
    Part::from(chart),
    Part::from(spec),
].into();

let response = agent.chat(prompt).await?;
```

Media is stored as `Bytes` — cloning into multiple stream frames is
refcounted, so a 30 MB PDF is never copied.
</details>

<details><summary><b>Bridge an MCP server's tools</b></summary>

Connect to an external [Model Context Protocol][mcp] server and expose
its tools to the agent. The bridge spawns the server, fetches its tool
catalog, and registers each as a regular `Tool` — the model can't tell
the difference between an in-process tool and an MCP-served one.

```rust
use localharness::{Agent, GeminiAgentConfig};
use localharness::types::McpServerConfig;

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key)
        .with_mcp_server(McpServerConfig::Stdio {
            command: "uvx".into(),
            args: vec!["mcp-server-fetch".into()],
        }),
).await?;
```

Today: stdio transport only; SSE/HTTP variants return an error.
Tools surface only — prompts, resources, sampling, and subscriptions
are out of scope.

[mcp]: https://modelcontextprotocol.io
</details>

<details><summary><b>Compact long conversations automatically</b></summary>

When prompt tokens for a turn exceed
`CapabilitiesConfig::compaction_threshold`, the agent summarizes the
oldest history entries via a separate Gemini call and replaces them
with one synthetic user-role turn tagged `[compacted prior context]`.
The most-recent 6 user/model pairs are kept verbatim; function-call /
response pairs are kept together.

```rust
use localharness::{Agent, CapabilitiesConfig, GeminiAgentConfig};

let mut caps = CapabilitiesConfig::unrestricted();
caps.compaction_threshold = Some(60_000);

let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key).with_capabilities(caps),
).await?;
```

Disabled by default (set to `None`). Typical values: 60-80% of your
model's max context window. Summarization failures fall back to a
drop-oldest strategy with the same tag.
</details>

<details><summary><b>Resume a conversation</b></summary>

```rust
let agent = Agent::start_gemini(
    GeminiAgentConfig::new(api_key).resume("conv-abc123"),
).await?;
```
</details>

---

## Built-in tools

The Gemini backend ships **13 tools** enabled by `BuiltinTool`,
auto-registered into the `ToolRunner` per `CapabilitiesConfig`. The
default `CapabilitiesConfig` exposes the read-only safety subset; call
`CapabilitiesConfig::unrestricted()` to enable everything.

| Tool | Read/Write | Description |
|------|:----------:|-------------|
| `list_directory` | R | Sorted children with `name`, `kind`, `size`. |
| `view_file` | R | UTF-8 lossy read with optional 1-indexed line range; 256 KiB cap. |
| `find_file` | R | Glob-matched recursive name search; 1000-match cap. |
| `search_directory` | R | Regex content search with optional file glob; 500-match cap. |
| `finish` | term | Terminate turn + capture structured output. |
| `create_file` | W | Atomic write via tempfile + rename; refuses to overwrite. |
| `edit_file` | W | Exact-once substring replace (or `replace_all`); atomic write. |
| `delete_file` | W | Remove a file or directory (recursive). Irreversible. |
| `rename_file` | W | Rename/move from → to. Atomic on Native when same filesystem; OPFS does read + write + delete. |
| `run_command` | W | Shell exec with timeout (default 30s / max 600s), 256 KiB output cap. |
| `generate_image` | W | Call the image model; returns base64 + MIME. |
| `ask_question` | I/O | Default no-op (returns `skipped: true`); register a custom `ask_question` tool for interactive UI. |
| `start_subagent` | spawn | One-shot text-only subagent with isolated context. Returns `{ final_response, finish_reason }`. |

Custom tools registered with the same name as a built-in **win** —
overrides are intentional.

---

## Architecture

```text
   ┌──────────────────────────────────────────────────────┐
   │  L1   Agent           start · chat · shutdown        │
   ├──────────────────────────────────────────────────────┤
   │  L2   Conversation    history · usage · streams      │
   │       ChatResponse    text · thoughts · tool_calls   │
   ├──────────────────────────────────────────────────────┤
   │  L3   Connection      transport abstraction          │
   │       GeminiConnection  reqwest + SSE + tool loop    │
   └──────────────────────────────────────────────────────┘
                              │  HTTPS (rustls)
                          Gemini API
```

Inside the Gemini agent loop:

```text
   user prompt ───►│                                         ▲
                   │ build GenerateContentRequest            │  emit Step
                   │ ───────► Gemini SSE ──────────► chunks  │  (text,
                   │             │                           │   thought,
                   │             ▼                           │   tool_call)
                   │   functionCall parts?  ────► dispatch ──┘
                   │             │              hooks→policy→tool_runner
                   │             ▼
                   │   append functionResponse ──► loop ─────┐
                   │                                         │
                   │   no more calls / finish ──► terminal Step
```

A single broadcast channel fans `Step`s out to every cursor
(`ChatResponse::chunks`, `text_stream`, `thoughts`, `tool_calls`). The
tool dispatch loop is inline inside the turn — no out-of-band
round-trip through a sidecar process.

---

## Run in the browser

`localharness` compiles to `wasm32-unknown-unknown`. The same `Agent`
loop that drives a CLI runs inside a browser tab — no server, no
backend, key stays in the page.

**Live demo:** [`localharness.xyz`](https://localharness.xyz/) — pick
a name, claim a subdomain, chat with the agent in `<name>.localharness.xyz`.

```toml
[target.'cfg(target_arch = "wasm32")'.dependencies]
localharness = { version = "0.10", default-features = false }
```

Run the demo locally:

```sh
git clone https://github.com/compusophy/localharness
cd localharness
./scripts/build-web.sh        # wasm-pack build → web/pkg/
python -m http.server 8765 -d web
```

**What works on wasm:** the full `Agent → Conversation → Connection
→ ToolRunner` chain, plus 12 of 13 built-in tools — the 4 portable
ones (`ask_question`, `finish`, `generate_image`, `start_subagent`)
and the 8 filesystem ones backed by OPFS (Origin Private File System;
per-origin sandbox, atomic writes): `list_directory`, `view_file`,
`find_file`, `search_directory`, `create_file`, `edit_file`,
`delete_file`, `rename_file`.

**What doesn't:** `run_command` and the MCP stdio bridge stay
native-only — the browser has no subprocess primitives.

```rust
use std::sync::Arc;
use localharness::filesystem::OpfsFilesystem;
use localharness::{CapabilitiesConfig, GeminiAgentConfig};

let cfg = GeminiAgentConfig::new(api_key)
    .with_capabilities(CapabilitiesConfig::unrestricted())
    .with_filesystem(Arc::new(OpfsFilesystem::new()));
```

To target a different backend (mock filesystem for tests, a custom
in-memory store), implement [`Filesystem`] and pass it to
`with_filesystem` the same way.

[`Filesystem`]: https://docs.rs/localharness/latest/localharness/filesystem/trait.Filesystem.html

### Platform: subdomains, wallets, registry

The reference browser app at `localharness.xyz` extends the SDK with
a self-sovereign identity layer:

- **Wildcard subdomain per user** (`<name>.localharness.xyz`). Each
  one is a separate origin → separate OPFS → separate working state,
  for free.
- **Master wallet** created on explicit user action (Create identity
  or Import seed) and persisted in the apex's OPFS — secp256k1 +
  BIP-39 via the `wallet` feature. The apex page hard-gates the claim
  form until an identity exists so visitors never end up with a
  silently-generated wallet they didn't ask for. Importable on any
  device via the 12-word seed phrase.
- **On-chain registry**`LocalharnessRegistry` as an EIP-2535
  Diamond on Tempo Moderato testnet
  ([`0x6f2858…2930`]https://moderato.tempo.xyz/address/0x6f2858b4b10bf8d4ea372a446e69bea8fbce2930).
  Names are claimed by signing a registration transaction with the
  master wallet. Every name is an ERC-721 NFT; every NFT has an
  ERC-6551 token-bound account (the agent's wallet).
- **Gasless onboarding.** Every user-initiated on-chain call —
  first-claim, `$LH` transfers, on-chain feedback, MAIN updates,
  add-device, send-from-TBA, per-turn payments — runs as a
  **sponsored Tempo Transaction** (tx type `0x76` — Tempo's native
  account-abstraction format). A bundle-side sponsor wallet signs
  as `fee_payer` and pays fees in AlphaUSD, so users hold zero of
  anything on day one.
- **Multi-device identity.** The diamond's TBA implementation is
  `MultiSignerAccount` — an ERC-6551 account with an
  `authorizedSigners` mapping + EIP-1271 `isValidSignature` on top
  of the standard surface. Add another device's EOA from apex
  admin; both devices sign for the same MAIN without sharing the
  seed.
- **In-system credits.** `$LH` is a TIP-20-shaped credit token with
  `currency() == "credits"` — explicitly NOT fee-token-eligible.
  Per-address daily allowance via the `CreditsFacet.claimDaily()`
  (one claim per UTC day). Claiming a subdomain costs 50 LH from
  the daily allowance; per-turn agent usage burns LH too. Owner-
  tunable cost knobs at every gate (`setRegistrationCost`,
  `setMainCost`, `setDailyAllowance`).
- **Composable subdomains.** Any subdomain renders as a module via
  `?embed=1`; any origin can host a grid of modules via
  `?compose=a,b,c`. Each module stays in its own origin (own OPFS,
  own signer iframe to apex) — composition via depth-1 sibling
  iframes, not nested ones, so the browser recursion limit is a
  non-issue.
- **Studio MVP — every subdomain can differentiate itself.** Tenant
  admin grows an "agent prompt" textarea; the contents append under
  an `=== Owner instructions ===` header on session start. First
  agent-differentiation primitive — future layers (tool allowlists,
  model picks, custom layouts) land the same way.
- **Cross-origin owner verification.** Tenant subdomains embed
  `apex/?signer=1` in a hidden iframe, send a postMessage sign
  challenge, recover the address from the signature, compare it to
  the on-chain owner. Visitors who don't hold the NFT see read-only
  mode automatically.

The on-chain stack lives behind the `wallet` feature and is exposed
via `pub mod registry` for off-bundle consumers (CLI tools, indexers,
back-ends). Contract source + Foundry deploy scripts live in
[`contracts/`](contracts/); architecture write-up in
[`contracts/README.md`](contracts/README.md). The next layers on the
frontier (MPP/x402 payment hooks, ERC-8004 reputation, a second
non-Gemini backend) are tracked in `CLAUDE.md`.

---

## Design notes (performance & safety)

- **Lock-free idle polling.** `Connection::is_idle()` reads an
  `AtomicBool`. Trigger handlers can hot-loop without contention.
- **Broadcast fan-out for steps.** Cursors subscribe without blocking
  the producer; replay buffer is bounded; slow consumers fail fast.
- **Bounded backpressure everywhere.** Step broadcast cap 256.
  Function-call dispatch capped at 16 rounds per turn (`MAX_TOOL_ROUNDS`).
- **Atomic file writes.** `create_file` and `edit_file` write through
  a `tempfile::NamedTempFile` in the same directory and rename into
  place — a crash mid-write never leaves a partially written file.
- **Bounded subprocess output.** `run_command` caps each stream at
  256 KiB and kills the child on timeout with `kill_on_drop`.
- **Component-wise path containment.** `workspace_only()` defeats
  prefix tricks (`/foo/bar-evil` vs `/foo/bar`).
- **Lock-free tool-context swap.** `arc_swap::ArcSwapOption` replaces
  the runtime context atomically across concurrent tool calls.
- **Typed errors.** Flat `thiserror` enum; `io::Error`,
  `serde_json::Error`, `reqwest::Error` fold via `#[from]`.
- **API key redaction.** `Debug` for `GeminiClient` prints
  `<redacted>` for the key.
- **Zero-copy media.** `Media::data` is `bytes::Bytes`. Cloning a part
  into multiple frames is a refcount bump.

---

## FAQ

**Does this need a server?** No. The crate uses `reqwest` to call the
Gemini REST API directly. No localhost daemon, no Go binary, no Python.

**How do I get a `GEMINI_API_KEY`?** From [Google AI Studio][aistudio].
Free tier is sufficient for development.

**Which model does it use?** Default `gemini-3.5-flash` for chat,
`gemini-3.1-flash-image-preview` for `generate_image`. Override with
`GeminiBackendConfig::with_model(...)`.

**Why does write-tool access require a policy?** Enabling tools that
write to disk or run commands without a policy is almost always a bug.
Add `with_policies(vec![allow_all()])` to opt in, or
`with_workspace(...)` to scope.

**MSRV?** Rust 1.85 (edition 2024).

**Async runtime?** Tokio.

**How do I get `tracing` logs?**

```rust
tracing_subscriber::fmt().with_env_filter("localharness=debug").init();
```

---

## License

[Apache-2.0](LICENSE).