Add SDK PoC: uniffi bindings for goose agent core

Exposes a minimal Agent API (configure + reply loop) over uniffi,
producing Python / Kotlin bindings from one cdylib.

Surface:
- Agent::new() / configure(ProviderSpec) / reply(prompt, EventSink)
- EventSink callback interface for streaming AgentEvents
This commit is contained in:
Alex Hancock
2026-05-18 10:36:25 -04:00
parent 904d4d3ea7
commit a6c2aab829
11 changed files with 1094 additions and 14 deletions
Generated
+303 -14
View File
@@ -187,7 +187,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -198,7 +198,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -278,6 +278,48 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "askama"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
dependencies = [
"askama_derive",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash 2.1.1",
"serde",
"serde_derive",
"syn 2.0.117",
]
[[package]]
name = "askama_parser"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow 0.7.15",
]
[[package]]
name = "asn1-rs"
version = "0.7.1"
@@ -338,6 +380,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "async-compat"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
dependencies = [
"futures-core",
"futures-io",
"once_cell",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-compression"
version = "0.4.41"
@@ -961,7 +1016,7 @@ dependencies = [
"arc-swap",
"bytes",
"either",
"fs-err",
"fs-err 3.3.0",
"http 1.4.0",
"http-body 1.0.1",
"hyper",
@@ -1044,6 +1099,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bat"
version = "0.26.1"
@@ -1838,6 +1902,29 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "castaway"
version = "0.2.4"
@@ -3387,7 +3474,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3702,7 +3789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4021,6 +4108,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "fs-err"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
dependencies = [
"autocfg",
]
[[package]]
name = "fs-err"
version = "3.3.0"
@@ -4554,6 +4650,17 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "goblin"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
dependencies = [
"log",
"plain",
"scroll",
]
[[package]]
name = "goose"
version = "1.34.0"
@@ -4586,7 +4693,7 @@ dependencies = [
"encoding_rs",
"env-lock",
"etcetera 0.11.0",
"fs-err",
"fs-err 3.3.0",
"fs2",
"futures",
"goose-acp-macros",
@@ -4854,6 +4961,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "goose-uniffi"
version = "1.34.0"
dependencies = [
"anyhow",
"async-trait",
"futures",
"goose",
"rmcp",
"serde_json",
"thiserror 2.0.18",
"tokio",
"uniffi",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -5710,7 +5832,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6602,7 +6724,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8613,7 +8735,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8672,7 +8794,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8864,6 +8986,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scroll"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
dependencies = [
"scroll_derive",
]
[[package]]
name = "scroll_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "scrypt"
version = "0.11.0"
@@ -8957,6 +9099,10 @@ name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "seq-macro"
@@ -9515,7 +9661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -10660,7 +10806,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11203,6 +11349,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
@@ -11863,6 +12018,128 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "uniffi"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f"
dependencies = [
"anyhow",
"camino",
"cargo_metadata",
"clap",
"uniffi_bindgen",
"uniffi_core",
"uniffi_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi_bindgen"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e"
dependencies = [
"anyhow",
"askama",
"camino",
"cargo_metadata",
"fs-err 2.11.0",
"glob",
"goblin",
"heck",
"indexmap 2.14.0",
"once_cell",
"serde",
"tempfile",
"textwrap",
"toml 0.5.11",
"uniffi_internal_macros",
"uniffi_meta",
"uniffi_pipeline",
"uniffi_udl",
]
[[package]]
name = "uniffi_core"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
dependencies = [
"anyhow",
"async-compat",
"bytes",
"once_cell",
"static_assertions",
]
[[package]]
name = "uniffi_internal_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933"
dependencies = [
"anyhow",
"indexmap 2.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "uniffi_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152"
dependencies = [
"camino",
"fs-err 2.11.0",
"once_cell",
"proc-macro2",
"quote",
"serde",
"syn 2.0.117",
"toml 0.5.11",
"uniffi_meta",
]
[[package]]
name = "uniffi_meta"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff"
dependencies = [
"anyhow",
"siphasher 0.3.11",
"uniffi_internal_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi_pipeline"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a"
dependencies = [
"anyhow",
"heck",
"indexmap 2.14.0",
"tempfile",
"uniffi_internal_macros",
]
[[package]]
name = "uniffi_udl"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716"
dependencies = [
"anyhow",
"textwrap",
"uniffi_meta",
"weedle2",
]
[[package]]
name = "unit-prefix"
version = "0.5.2"
@@ -12286,6 +12563,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weedle2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -12357,7 +12643,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -12842,6 +13128,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -12859,7 +13148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/*",
"sdk",
# Mainly for cargo-machete to not error out during inspection.
"vendor/v8",
]
+1
View File
@@ -0,0 +1 @@
generated/
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "goose-uniffi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Multi-language bindings (Python, Kotlin) for the Goose agent core via UniFFI"
[lib]
name = "goose_uniffi"
crate-type = ["cdylib", "staticlib", "rlib"]
[[bin]]
name = "goose-uniffi-bindgen"
path = "src/bin/uniffi-bindgen.rs"
[dependencies]
goose = { path = "../crates/goose" }
uniffi = { version = "0.29", features = ["tokio", "cli"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
futures = { workspace = true }
anyhow = { workspace = true }
thiserror = "2"
async-trait = { workspace = true }
rmcp = { workspace = true }
serde_json = { workspace = true }
+102
View File
@@ -0,0 +1,102 @@
# goose-uniffi
PoC multi-language SDK for the Goose Rust agent core, using [UniFFI](https://mozilla.github.io/uniffi-rs/).
This crate compiles the `goose` Rust agent into one dynamic library plus generated bindings for **Python** and **Kotlin**. The same `libgoose_uniffi` powers every language, so adding a feature in Rust ships to all of them.
> **TypeScript:** UniFFI core does not ship a TS backend, and the React-Native-only `uniffi-bindgen-react-native` doesn't fit a plain-Node use case. For TS clients today, use the existing ACP-over-stdio path in [`crates/goose-sdk`](../crates/goose-sdk) / [`ui/sdk`](../ui/sdk) instead.
This sits next to two other client paths:
| Path | Transport | Used by |
|---|---|---|
| v1 | HTTP (`goose-server`) | Electron desktop |
| v2 | ACP over stdio/HTTP/WS (`crates/goose/src/acp`) | `ui/text` TUI, `ui/sdk` TS client, `crates/goose-sdk` Rust client |
| **this PoC** | **In-process FFI** (uniffi) | Any host language embedding goose |
## API surface
Deliberately tiny:
```rust
Agent::new()
Agent::configure(ProviderSpec, Vec<ExtensionSpec>) -> session_id
Agent::reply(prompt, EventSink) // streaming via callback
Agent::reply_collect(prompt) -> Vec<AgentEvent> // one-shot
```
`EventSink` is a foreign callback interface — the host implements it, Rust invokes it for every assistant chunk, tool request, tool response, thinking, plus `on_done` / `on_error`.
## Quick start
Each example sends the prompt `"ping apple.com"` to a configured agent and prints the streamed response.
```bash
cd sdk
just # list recipes
just python # build, generate bindings, run ping_apple.py
just kotlin # build, generate bindings, fetch jna, compile, run PingApple.kt
```
Prereqs: a Rust toolchain, `python3`, `kotlinc` (`brew install kotlin`) + a JDK, and one `goose configure`-d provider on your machine. Override at runtime with `GOOSE_PROVIDER=openai GOOSE_MODEL=gpt-4o`.
## Code examples
All three speak the same shape: build an `Agent`, `configure` it with a provider, implement an `EventSink`, call `reply`.
`AgentEvent` is a tagged union — `AssistantText { text }`, `Thinking { text }`, `ToolRequest { id, name, arguments }`, `ToolResponse { id, output, isError }`.
### Python — [`examples/ping_apple.py`](examples/ping_apple.py)
```python
from goose_uniffi import Agent, ProviderSpec, ExtensionSpec, EventSink, AgentEvent
class Printer(EventSink):
def on_event(self, event):
if isinstance(event, AgentEvent.ASSISTANT_TEXT):
print(event.text, end="", flush=True)
elif isinstance(event, AgentEvent.TOOL_REQUEST):
print(f"{event.name} {event.arguments}")
def on_error(self, error): print(f"error: {error}")
def on_done(self): pass
agent = Agent()
agent.configure(
ProviderSpec(name="anthropic", model="claude-sonnet-4-5"),
[ExtensionSpec.BUILTIN(name="developer")],
)
agent.reply("ping apple.com", Printer())
```
Run with `just python`.
### Kotlin — [`examples/PingApple.kt`](examples/PingApple.kt)
```kotlin
import uniffi.goose_uniffi.*
class Printer : EventSink {
override fun onEvent(event: AgentEvent) = when (event) {
is AgentEvent.AssistantText -> print(event.text)
is AgentEvent.ToolRequest -> println("${event.name} ${event.arguments}")
else -> {}
}
override fun onError(error: String) = System.err.println("error: $error")
override fun onDone() {}
}
fun main() {
val agent = Agent()
agent.configure(
ProviderSpec(name = "anthropic", model = "claude-sonnet-4-5"),
listOf(ExtensionSpec.Builtin(name = "developer")),
)
agent.reply("ping apple.com", Printer())
}
```
Run with `just kotlin`.
## Out of scope (for now)
Extension/MCP wiring, permission/elicitation callbacks, session resume, recipes, hooks, mode switching, mid-stream cancellation. All present in the underlying `goose` crate — just not bound yet.
+83
View File
@@ -0,0 +1,83 @@
package examples
import uniffi.goose_uniffi.Agent
import uniffi.goose_uniffi.AgentEvent
import uniffi.goose_uniffi.EventSink
import uniffi.goose_uniffi.ExtensionSpec
import uniffi.goose_uniffi.ProviderSpec
/** Minimal goose SDK demo: ask the agent to ping apple.com. */
private object Style {
const val DIM = "\u001B[2m"
const val CYAN = "\u001B[36m"
const val GREEN = "\u001B[32m"
const val RED = "\u001B[31m"
const val RESET = "\u001B[0m"
}
private fun String.paint(color: String) = "$color$this${Style.RESET}"
private fun String.preview(maxLines: Int = 3, maxWidth: Int = 100): String =
lineSequence()
.filter { it.isNotBlank() }
.map { it.take(maxWidth) }
.take(maxLines)
.joinToString("\n ")
private class Printer : EventSink {
private var midText = false
override fun onEvent(event: AgentEvent) {
when (event) {
is AgentEvent.AssistantText -> {
print(event.text)
midText = true
}
is AgentEvent.ToolRequest -> {
endTextLine()
val args = event.arguments.replace("\n", " ").take(120)
println("${event.name}".paint(Style.CYAN) + " " + args.paint(Style.DIM))
}
is AgentEvent.ToolResponse -> {
endTextLine()
val color = if (event.isError) Style.RED else Style.GREEN
val marker = if (event.isError) "" else ""
println(marker.paint(color) + " " + event.output.preview().paint(Style.DIM))
println()
}
is AgentEvent.Thinking -> Unit
}
System.out.flush()
}
override fun onError(error: String) {
System.err.println("\n${"error:".paint(Style.RED)} $error")
}
override fun onDone() = endTextLine()
private fun endTextLine() {
if (midText) {
println()
midText = false
}
}
}
fun main() {
System.err.println("configuring agent…".paint(Style.DIM))
val agent = Agent().apply {
configure(
ProviderSpec(
name = System.getenv("GOOSE_PROVIDER"),
model = System.getenv("GOOSE_MODEL"),
),
listOf(ExtensionSpec.Builtin(name = "developer")),
)
}
System.err.println("> ping apple.com".paint(Style.DIM) + "\n")
agent.reply("ping apple.com", Printer())
}
+73
View File
@@ -0,0 +1,73 @@
# Examples
All three examples do the same thing: build an `Agent`, configure it with a provider, and send the prompt `"ping apple.com"`. The agent will call the `developer__shell` tool (or equivalent) and stream the output back.
## Prerequisites
```bash
# 1. From the repo root, build the cdylib and bindgen binary:
cargo build -p goose-uniffi
# 2. Configure a goose provider (only needs to be done once on your machine):
goose configure
# 3. Generate bindings for whichever languages you want to use:
LIB=./target/debug/libgoose_uniffi.dylib # .so on Linux, .dll on Windows
./target/debug/goose-uniffi-bindgen generate --library $LIB --language python --out-dir ./sdk/generated
./target/debug/goose-uniffi-bindgen generate --library $LIB --language kotlin --out-dir ./sdk/generated
```
All examples assume `libgoose_uniffi.{dylib,so}` is on the dynamic-library load path. The snippets below set that automatically.
---
## Python
**File:** `ping_apple.py`
```bash
DYLD_LIBRARY_PATH=./target/debug \
LD_LIBRARY_PATH=./target/debug \
python3 sdk/examples/ping_apple.py
```
Requires Python 3.8+. No pip packages needed — `ctypes` ships with the stdlib.
---
## Kotlin
**File:** `PingApple.kt`
Compile and run via `kotlinc` + `java`:
```bash
# Compile (jna is needed by the uniffi-generated kotlin runtime)
JNA_JAR=$(find ~/.gradle /opt/homebrew /usr/local -name 'jna-5.*.jar' 2>/dev/null | head -1)
# If you don't have jna locally, grab it:
# curl -L -o jna.jar https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar
# JNA_JAR=./jna.jar
kotlinc -cp "$JNA_JAR" \
sdk/generated/uniffi/goose_uniffi/goose_uniffi.kt \
sdk/examples/PingApple.kt \
-include-runtime -d ping_apple.jar
DYLD_LIBRARY_PATH=./target/debug \
LD_LIBRARY_PATH=./target/debug \
java -cp "ping_apple.jar:$JNA_JAR" examples.PingAppleKt
```
Requires `kotlinc` (`brew install kotlin`) and a JDK.
---
## Notes
- The first run creates a session under `~/.local/share/goose/sessions/`. Subsequent runs make new sessions; this PoC doesn't expose resume.
- Provider/model come from `~/.config/goose/config.yaml` by default. Override per-run with `GOOSE_PROVIDER=openai GOOSE_MODEL=gpt-4o just python`.
- Each example loads the `developer` builtin extension so the agent can run shell commands like `ping`.
## TypeScript
Not supported by this SDK. UniFFI core has no TypeScript backend, and the React-Native-only `uniffi-bindgen-react-native` doesn't fit a plain-Node use case. For TS, use the existing ACP path in [`../../crates/goose-sdk`](../../crates/goose-sdk) and [`../../ui/sdk`](../../ui/sdk) instead.
+88
View File
@@ -0,0 +1,88 @@
"""Minimal goose SDK demo: ask the agent to ping apple.com."""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "generated"))
from goose_uniffi import Agent, AgentEvent, EventSink, ExtensionSpec, ProviderSpec # noqa: E402
@dataclass(frozen=True)
class Style:
dim: str = "\033[2m"
cyan: str = "\033[36m"
green: str = "\033[32m"
red: str = "\033[31m"
reset: str = "\033[0m"
def paint(self, color: str, text: str) -> str:
return f"{color}{text}{self.reset}"
S = Style()
def _preview(output: str, *, max_lines: int = 3, max_width: int = 100) -> str:
lines = (line[:max_width] for line in output.splitlines() if line.strip())
return "\n ".join(list(lines)[:max_lines])
class Printer(EventSink):
"""Pretty-prints agent events to the terminal."""
def __init__(self) -> None:
self._mid_text = False
def on_event(self, event: AgentEvent) -> None:
if isinstance(event, AgentEvent.ASSISTANT_TEXT):
print(event.text, end="", flush=True)
self._mid_text = True
return
self._end_text_line()
if isinstance(event, AgentEvent.TOOL_REQUEST):
args = event.arguments.replace("\n", " ")[:120]
print(f"{S.paint(S.cyan, '' + event.name)} {S.paint(S.dim, args)}", flush=True)
elif isinstance(event, AgentEvent.TOOL_RESPONSE):
color = S.red if event.is_error else S.green
marker = "" if event.is_error else ""
print(f"{S.paint(color, marker)} {S.paint(S.dim, _preview(event.output))}\n", flush=True)
def on_error(self, error: str) -> None:
print(f"\n{S.paint(S.red, 'error:')} {error}", file=sys.stderr)
def on_done(self) -> None:
self._end_text_line()
def _end_text_line(self) -> None:
if self._mid_text:
print()
self._mid_text = False
def main() -> None:
print(S.paint(S.dim, "configuring agent…"), file=sys.stderr)
agent = Agent()
agent.configure(
ProviderSpec(
name=os.environ.get("GOOSE_PROVIDER"),
model=os.environ.get("GOOSE_MODEL"),
),
[ExtensionSpec.BUILTIN(name="developer")],
)
print(S.paint(S.dim, "> ping apple.com") + "\n", file=sys.stderr)
agent.reply("ping apple.com", Printer())
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
set shell := ["bash", "-cu"]
set working-directory := '..'
lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" }
lib_dir := "./target/debug"
lib_path := lib_dir / "libgoose_uniffi." + lib_ext
bindgen := "./target/debug/goose-uniffi-bindgen"
gen_dir := "./sdk/generated"
# ANSI helpers
bold := '\033[1m'
dim := '\033[2m'
cyan := '\033[36m'
reset := '\033[0m'
default:
@just --list --justfile {{justfile()}}
# ─── shared steps ────────────────────────────────────────────────
_build:
@printf "{{dim}}┌─ building goose-uniffi (rust → cdylib){{reset}}\n"
@printf "{{dim}}│ cargo build -p goose-uniffi{{reset}}\n"
@cargo build -p goose-uniffi -q
@printf "{{dim}}└─ {{reset}}{{bold}}libgoose_uniffi.{{lib_ext}}{{reset}} {{dim}}ready{{reset}}\n\n"
_generate lang: _build
@printf "{{dim}}┌─ generating {{lang}} bindings via uniffi-bindgen{{reset}}\n"
@printf "{{dim}}│ goose-uniffi-bindgen generate --language {{lang}}{{reset}}\n"
@{{bindgen}} generate --library {{lib_path}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null
@printf "{{dim}}└─ bindings written to {{gen_dir}}{{reset}}\n\n"
_header lang:
@printf "\n{{bold}}{{cyan}}━━━ goose-uniffi · {{lang}} demo ━━━{{reset}}\n"
@printf "{{dim}}prompt: \"ping apple.com\" · extension: developer (builtin){{reset}}\n\n"
# ─── language demos ──────────────────────────────────────────────
python: (_header "python") (_generate "python")
@printf "{{dim}}┌─ running python example{{reset}}\n"
@printf "{{dim}}│ python3 sdk/examples/ping_apple.py{{reset}}\n"
@printf "{{dim}}└──────────────────────────────────────{{reset}}\n\n"
@DYLD_LIBRARY_PATH={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \
python3 sdk/examples/ping_apple.py
kotlin: (_header "kotlin") (_generate "kotlin")
@if [ ! -f sdk/examples/jna.jar ]; then \
printf "{{dim}}┌─ downloading jna.jar{{reset}}\n"; \
curl -sSL -o sdk/examples/jna.jar \
https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \
printf "{{dim}}└─ jna.jar ready{{reset}}\n\n"; \
fi
@printf "{{dim}}┌─ compiling kotlin{{reset}}\n"
@printf "{{dim}}│ kotlinc PingApple.kt + generated bindings{{reset}}\n"
@kotlinc -cp sdk/examples/jna.jar -nowarn \
{{gen_dir}}/uniffi/goose_uniffi/goose_uniffi.kt \
sdk/examples/PingApple.kt \
-include-runtime -d sdk/examples/ping_apple.jar 2>/dev/null
@printf "{{dim}}└─ ping_apple.jar built{{reset}}\n\n"
@printf "{{dim}}┌─ running kotlin example{{reset}}\n"
@printf "{{dim}}│ java -cp ping_apple.jar examples.PingAppleKt{{reset}}\n"
@printf "{{dim}}└──────────────────────────────────────{{reset}}\n\n"
@java -Djna.library.path={{lib_dir}} \
--enable-native-access=ALL-UNNAMED \
-cp sdk/examples/ping_apple.jar:sdk/examples/jna.jar examples.PingAppleKt
+3
View File
@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}
+346
View File
@@ -0,0 +1,346 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;
use futures::StreamExt;
use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::types::SessionConfig as CoreSessionConfig;
use goose::agents::{Agent as CoreAgent, AgentEvent as CoreAgentEvent};
use goose::config::{Config, GooseMode, DEFAULT_EXTENSION_TIMEOUT};
use goose::conversation::message::{Message, MessageContent};
use goose::model::ModelConfig;
use goose::providers;
use goose::session::session_manager::SessionType;
use tokio::runtime::Runtime;
uniffi::setup_scaffolding!();
static RUNTIME: OnceLock<Runtime> = OnceLock::new();
fn rt() -> &'static Runtime {
RUNTIME.get_or_init(|| Runtime::new().expect("failed to build tokio runtime"))
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum GooseError {
#[error("{0}")]
Generic(String),
}
macro_rules! err_from {
($($t:ty),* $(,)?) => {$(
impl From<$t> for GooseError {
fn from(e: $t) -> Self { GooseError::Generic(e.to_string()) }
}
)*};
}
err_from!(anyhow::Error, goose::model::ConfigError, std::io::Error);
#[derive(uniffi::Record, Clone)]
pub struct ProviderSpec {
pub name: Option<String>,
pub model: Option<String>,
}
#[derive(uniffi::Enum, Clone)]
pub enum ExtensionSpec {
Builtin {
name: String,
},
Stdio {
name: String,
cmd: String,
args: Vec<String>,
envs: HashMap<String, String>,
},
StreamableHttp {
name: String,
uri: String,
headers: HashMap<String, String>,
},
}
impl ExtensionSpec {
fn into_config(self) -> ExtensionConfig {
match self {
ExtensionSpec::Builtin { name } => ExtensionConfig::Builtin {
name,
description: String::new(),
display_name: None,
timeout: Some(DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
available_tools: vec![],
},
ExtensionSpec::Stdio {
name,
cmd,
args,
envs,
} => ExtensionConfig::Stdio {
name,
description: String::new(),
cmd,
args,
envs: Envs::new(envs),
env_keys: vec![],
timeout: Some(DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
available_tools: vec![],
},
ExtensionSpec::StreamableHttp { name, uri, headers } => {
ExtensionConfig::StreamableHttp {
name,
description: String::new(),
uri,
envs: Envs::new(HashMap::new()),
env_keys: vec![],
headers,
timeout: Some(DEFAULT_EXTENSION_TIMEOUT),
socket: None,
bundled: None,
available_tools: vec![],
}
}
}
}
}
#[derive(uniffi::Enum, Clone, Debug)]
pub enum AgentEvent {
AssistantText {
text: String,
},
Thinking {
text: String,
},
ToolRequest {
id: String,
name: String,
arguments: String,
},
ToolResponse {
id: String,
output: String,
is_error: bool,
},
}
#[uniffi::export(callback_interface)]
pub trait EventSink: Send + Sync {
fn on_event(&self, event: AgentEvent);
fn on_error(&self, error: String);
fn on_done(&self);
}
#[derive(uniffi::Object)]
pub struct Agent {
inner: Arc<CoreAgent>,
session_id: tokio::sync::Mutex<Option<String>>,
}
#[uniffi::export]
impl Agent {
#[uniffi::constructor]
pub fn new() -> Arc<Self> {
let inner = rt().block_on(async { Arc::new(CoreAgent::new()) });
Arc::new(Self {
inner,
session_id: tokio::sync::Mutex::new(None),
})
}
pub fn configure(
&self,
provider: ProviderSpec,
extensions: Vec<ExtensionSpec>,
) -> Result<String, GooseError> {
rt().block_on(async {
let cfg = Config::global();
let provider_name = provider
.name
.or_else(|| cfg.get_goose_provider().ok())
.ok_or_else(|| {
GooseError::Generic(
"no provider: set ProviderSpec.name or run `goose configure`".into(),
)
})?;
let model_name = provider
.model
.or_else(|| cfg.get_goose_model().ok())
.ok_or_else(|| {
GooseError::Generic(
"no model: set ProviderSpec.model or run `goose configure`".into(),
)
})?;
let cwd = std::env::current_dir()?;
let session = self
.inner
.config
.session_manager
.create_session(
cwd,
"uniffi-sdk".to_string(),
SessionType::User,
GooseMode::default(),
)
.await?;
let ext_configs: Vec<ExtensionConfig> =
extensions.into_iter().map(|e| e.into_config()).collect();
let model_config = ModelConfig::new(&model_name)?.with_canonical_limits(&provider_name);
let prov =
providers::create(&provider_name, model_config, ext_configs.clone()).await?;
self.inner.update_provider(prov, &session.id).await?;
if !ext_configs.is_empty() {
let results = self
.inner
.add_extensions_bulk(ext_configs, &session.id)
.await?;
for r in &results {
if !r.success {
return Err(GooseError::Generic(format!(
"extension {} failed to load: {}",
r.name,
r.error.clone().unwrap_or_default()
)));
}
}
}
*self.session_id.lock().await = Some(session.id.clone());
Ok(session.id)
})
}
pub fn reply_collect(&self, prompt: String) -> Result<Vec<AgentEvent>, GooseError> {
rt().block_on(async {
let session_id = self
.session_id
.lock()
.await
.clone()
.ok_or_else(|| GooseError::Generic("call configure() first".into()))?;
let session_config = CoreSessionConfig {
id: session_id,
schedule_id: None,
max_turns: None,
retry_config: None,
};
let mut stream = self
.inner
.reply(Message::user().with_text(&prompt), session_config, None)
.await?;
let mut out = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(CoreAgentEvent::Message(msg)) => {
for content in &msg.content {
if let Some(ev) = content_to_event(content) {
out.push(ev);
}
}
}
Ok(_) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
})
}
pub fn reply(&self, prompt: String, sink: Box<dyn EventSink>) -> Result<(), GooseError> {
rt().block_on(async {
let session_id = self
.session_id
.lock()
.await
.clone()
.ok_or_else(|| GooseError::Generic("call configure() first".into()))?;
let session_config = CoreSessionConfig {
id: session_id,
schedule_id: None,
max_turns: None,
retry_config: None,
};
let user_message = Message::user().with_text(&prompt);
let mut stream = self.inner.reply(user_message, session_config, None).await?;
while let Some(item) = stream.next().await {
match item {
Ok(CoreAgentEvent::Message(msg)) => {
for content in &msg.content {
if let Some(ev) = content_to_event(content) {
sink.on_event(ev);
}
}
}
Ok(_) => {}
Err(e) => {
sink.on_error(e.to_string());
return Err(e.into());
}
}
}
sink.on_done();
Ok(())
})
}
}
fn content_to_event(content: &MessageContent) -> Option<AgentEvent> {
use rmcp::model::RawContent;
match content {
MessageContent::Text(t) => Some(AgentEvent::AssistantText {
text: t.text.clone(),
}),
MessageContent::Thinking(t) => Some(AgentEvent::Thinking {
text: t.thinking.clone(),
}),
MessageContent::ToolRequest(req) => {
let (name, arguments) = match &req.tool_call {
Ok(call) => (
call.name.to_string(),
call.arguments
.as_ref()
.map(|a| serde_json::to_string(a).unwrap_or_default())
.unwrap_or_default(),
),
Err(e) => ("<error>".into(), e.to_string()),
};
Some(AgentEvent::ToolRequest {
id: req.id.clone(),
name,
arguments,
})
}
MessageContent::ToolResponse(resp) => {
let (output, is_error) = match &resp.tool_result {
Ok(result) => {
let text = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
(text, result.is_error.unwrap_or(false))
}
Err(e) => (e.to_string(), true),
};
Some(AgentEvent::ToolResponse {
id: resp.id.clone(),
output,
is_error,
})
}
_ => None,
}
}