Writing a Plugin
Build a plugin that adds a table, a tool, session context, settings and an API route, then switch it on.
This guide builds tasks, a to-do list per profile. It touches every part of the plugin contract: a table, a tool, context for new sessions, settings, and an API route. The code compiles against the current commonos crate.
Create the crate
Plugins live in crates/common-<name>. Add the crate to the workspace and give it these dependencies:
[package]
name = "common-tasks"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
axum.workspace = true
common-agent.workspace = true
commonos.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = trueWrite the schema
A plugin owns its tables. The schema runs on every start, so every statement must be safe to repeat.
CREATE TABLE IF NOT EXISTS task_items (
id bigserial PRIMARY KEY,
profile_id text NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
title text NOT NULL,
done boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS task_items_profile_idx ON task_items (profile_id, done);Key every row by profile_id. That keeps one profile’s data away from another’s.
Implement Plugin
//! CommonTasks: a to-do list per profile.
use std::sync::Arc;
use async_trait::async_trait;
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{Json, Router};
use common_agent::{ContextSource, Risk, Tool, ToolContext, ToolError};
use commonos::{ApiResult, Plugin, PluginContext};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::PgPool;
pub struct CommonTasks;
/// `[plugins.tasks]` in `commonos.toml`.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TaskSettings {
pub max_open: i64,
}
impl Default for TaskSettings {
fn default() -> Self {
Self { max_open: 20 }
}
}
#[async_trait]
impl Plugin for CommonTasks {
fn name(&self) -> &'static str {
"tasks"
}
fn schema(&self) -> &'static str {
include_str!("schema.sql")
}
async fn register(&self, ctx: &mut PluginContext) -> anyhow::Result<()> {
let settings: TaskSettings = ctx.settings()?;
let pool = ctx.store.pool().clone();
ctx.tool(Arc::new(AddTask { pool: pool.clone(), settings }));
ctx.context(Arc::new(OpenTasks { pool: pool.clone() }));
ctx.routes(Router::new().route("/api/tasks", get(list_route)).with_state(pool));
Ok(())
}
}#[serde(default)] on the settings struct means a missing [plugins.tasks] table, or a missing key, falls back to Default.
Add a tool
struct AddTask {
pool: PgPool,
settings: TaskSettings,
}
#[async_trait]
impl Tool for AddTask {
fn name(&self) -> &str {
"add_task"
}
fn description(&self) -> &str {
"Add an item to the person's to-do list. Use a short title that starts with a verb."
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": { "title": { "type": "string" } },
"required": ["title"]
})
}
fn risk(&self) -> Risk {
// Only this profile's own list changes.
Risk::Read
}
async fn call(&self, ctx: &ToolContext, args: Value) -> Result<String, ToolError> {
let title = args["title"]
.as_str()
.filter(|title| !title.trim().is_empty())
.ok_or_else(|| ToolError::InvalidArgs("title is required".into()))?;
let profile = &ctx.turn.profile_id;
let open: i64 = sqlx::query_scalar(
"SELECT count(*) FROM task_items WHERE profile_id = $1 AND NOT done",
)
.bind(profile)
.fetch_one(&self.pool)
.await
.map_err(|e| ToolError::Failed(e.to_string()))?;
if open >= self.settings.max_open {
return Err(ToolError::Failed(format!(
"the list already has {open} open tasks (max {})",
self.settings.max_open
)));
}
sqlx::query("INSERT INTO task_items (profile_id, title) VALUES ($1, $2)")
.bind(profile)
.bind(title.trim())
.execute(&self.pool)
.await
.map_err(|e| ToolError::Failed(e.to_string()))?;
Ok(format!("Added: {}", title.trim()))
}
}ctx.turn.profile_id says which profile is calling. Errors go back to the model, which can fix its arguments and retry.
Add session context
A ContextSource puts open tasks in the prompt of each new session:
struct OpenTasks {
pool: PgPool,
}
#[async_trait]
impl ContextSource for OpenTasks {
fn name(&self) -> &str {
"tasks"
}
async fn context(&self, profile_id: &str) -> anyhow::Result<String> {
let titles: Vec<String> = sqlx::query_scalar(
"SELECT title FROM task_items WHERE profile_id = $1 AND NOT done ORDER BY id",
)
.bind(profile_id)
.fetch_all(&self.pool)
.await?;
if titles.is_empty() {
return Ok(String::new());
}
Ok(format!("OPEN TASKS\n- {}", titles.join("\n- ")))
}
}Return an empty string when there is nothing to say; empty sections are left out. Context is frozen per session, so a task added now shows up next session.
Add a route
Routes are merged into the dashboard server, which binds to 127.0.0.1 only.
#[derive(Deserialize)]
struct ListQuery {
profile: String,
}
async fn list_route(State(pool): State<PgPool>, Query(query): Query<ListQuery>) -> ApiResult<Value> {
let rows: Vec<(i64, String, bool)> = sqlx::query_as(
"SELECT id, title, done FROM task_items WHERE profile_id = $1 ORDER BY id",
)
.bind(&query.profile)
.fetch_all(&pool)
.await?;
let tasks: Vec<Value> = rows
.into_iter()
.map(|(id, title, done)| json!({ "id": id, "title": title, "done": done }))
.collect();
Ok(Json(json!({ "tasks": tasks })))
}ApiResult turns any error into an HTTP 500 with {"error": "…"}.
Install it in cos
Plugins are compiled into cos, so this step needs a build from source. Add the crate to crates/commonos-cli/Cargo.toml, then to installed_plugins():
fn installed_plugins() -> Vec<Arc<dyn Plugin>> {
vec![
Arc::new(CommonBill),
Arc::new(CommonMemory),
Arc::new(CommonDelegate),
Arc::new(CommonTasks),
]
}Switch it on
With no enabled list, every installed plugin runs. With a list, add tasks to it:
[plugins]
enabled = ["bill", "memory", "delegate", "tasks"]
[plugins.tasks]
max_open = 10Restart cos serve. GET /api/plugins now lists tasks, every profile without a tools list has add_task, and GET /api/tasks?profile=Household returns the list.
Checklist
- Use only the contract:
hook,tool,context,routes,settingsandschema(). - Own your tables. Don’t read the core’s or another plugin’s tables.
- Keep schemas idempotent:
CREATE … IF NOT EXISTS. - Use runtime
sqlx::querywith bound parameters. - Use
Risk::Writefor anything that changes the outside world. - Keep real prices and model ids out of code.
- Run
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warningsand the tests before you push.