# ds-api
[](https://crates.io/crates/ds-api)
[](https://docs.rs/ds-api)
[](https://github.com/ozongzi/ds-api/blob/main/LICENSE-MIT)
A Rust SDK for building LLM agents on top of DeepSeek (and any OpenAI-compatible API). Define tools in plain Rust, plug them into an agent, and consume a stream of events as the model thinks, calls tools, and responds.
---
## Quickstart
Set your API key and add the dependency:
```bash
export DEEPSEEK_API_KEY="sk-..."
```
```toml
# Cargo.toml
[dependencies]
ds-api = "0.6.0"
futures = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
```
```rust
use ds_api::{AgentEvent, DeepseekAgent, tool};
use futures::StreamExt;
use serde_json::{Value, json};
struct Search;
#[tool]
impl ds_api::Tool for Search {
/// Search the web and return results.
/// query: the search query
async fn search(&self, query: String) -> Value {
json!({ "results": format!("results for: {query}") })
}
}
#[tokio::main]
async fn main() {
let token = std::env::var("DEEPSEEK_API_KEY").unwrap();
let mut stream = DeepseekAgent::new(token)
.add_tool(Search)
.chat("What's the latest news about Rust?");
while let Some(event) = stream.next().await {
match event.unwrap() {
AgentEvent::Token(text) => print!("{text}"),
AgentEvent::ToolCall(c) => println!("\n[calling {}]", c.name),
AgentEvent::ToolResult(r) => println!("[result] {}", r.result),
AgentEvent::ReasoningToken(t) => print!("{t}"),
}
}
}
```
The agent runs the full loop for you: it calls the model, dispatches any tool calls, feeds the results back, and keeps going until the model stops requesting tools.
---
## Defining tools
Annotate an `impl Tool for YourStruct` block with `#[tool]`. Each method becomes a callable tool:
- **Doc comment on the impl block** → tool description
- **`/// param: description`** lines in each method's doc comment → argument descriptions
- Return type just needs to be `serde::Serialize` — the macro handles the JSON schema
```rust
use ds_api::tool;
use serde_json::{Value, json};
struct Calculator;
#[tool]
impl ds_api::Tool for Calculator {
/// Add two numbers together.
/// a: first number
/// b: second number
async fn add(&self, a: f64, b: f64) -> Value {
json!({ "result": a + b })
}
/// Multiply two numbers.
/// a: first number
/// b: second number
async fn multiply(&self, a: f64, b: f64) -> Value {
json!({ "result": a * b })
}
}
```
One struct can have multiple methods — they register as separate tools. Stack as many tools as you need with `.add_tool(...)`.
---
## Streaming
Call `.with_streaming()` to get token-by-token output instead of waiting for the full response:
```rust
let mut stream = DeepseekAgent::new(token)
.with_streaming()
.add_tool(Search)
.chat("Search for something and summarise it");
while let Some(event) = stream.next().await {
match event.unwrap() {
AgentEvent::Token(t) => { print!("{t}"); io::stdout().flush().ok(); }
AgentEvent::ToolCall(c) => {
// In streaming mode, ToolCall fires once per SSE chunk.
// First chunk: c.delta is empty, c.name is set — good moment to show "calling X".
// Subsequent chunks: c.delta contains incremental argument JSON.
// In non-streaming mode, exactly one ToolCall fires with the full args in c.delta.
if c.delta.is_empty() { println!("\n[calling {}]", c.name); }
}
AgentEvent::ToolResult(r) => println!("[done] {}: {}", r.name, r.result),
_ => {}
}
}
```
### AgentEvent reference
| `Token(String)` | Model is speaking | Streaming: one fragment per chunk. Non-streaming: whole reply at once. |
| `ReasoningToken(String)` | Model is thinking | Only from reasoning models (e.g. `deepseek-reasoner`). |
| `ToolCall(ToolCallChunk)` | Tool call in progress | `chunk.id`, `chunk.name`, `chunk.delta`. Streaming: multiple per call. Non-streaming: one per call. |
| `ToolResult(ToolCallResult)` | Tool finished | `result.name`, `result.args`, `result.result`. |
---
## Using a different model or provider
Any OpenAI-compatible endpoint works:
```rust
// OpenRouter
let agent = DeepseekAgent::custom(
"sk-or-...",
"https://openrouter.ai/api/v1",
"meta-llama/llama-3.3-70b-instruct:free",
);
// deepseek-reasoner (think before responding)
let agent = DeepseekAgent::new(token)
.with_model("deepseek-reasoner");
```
---
## Injecting messages mid-run
You can send a message into a running agent loop — useful when the user types something while the agent is still executing tools:
```rust
let (agent, tx) = DeepseekAgent::new(token)
.with_streaming()
.add_tool(SlowTool)
.with_interrupt_channel();
// From any task, at any time:
tx.send("Actually, cancel that and do X instead.".into()).unwrap();
// The agent picks it up after the current tool-execution round finishes.
let mut stream = agent.chat("Do the slow thing.");
```
---
## MCP tools
MCP (Model Context Protocol) lets you use external processes as tools — Node scripts, Python services, anything that speaks MCP over stdio:
```rust
// Requires the `mcp` feature
let agent = DeepseekAgent::new(token)
.add_tool(McpTool::stdio("npx", &["-y", "@playwright/mcp"]).await?);
```
---
## familiar
This repo includes `familiar`, a full Discord + web chat app built on ds-api. It shows persistent conversation history, multi-tool agents, streaming UI, and MCP integration in a real app. See `familiar/` for details.
---
## Contributing
PRs welcome. Keep changes focused; update public API docs when behaviour changes.
## License
MIT OR Apache-2.0