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:

crates/common-tasks/Cargo.toml
[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 = true

Write the schema

A plugin owns its tables. The schema runs on every start, so every statement must be safe to repeat.

crates/common-tasks/src/schema.sql
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

crates/common-tasks/src/lib.rs
//! 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

crates/common-tasks/src/lib.rs
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:

crates/common-tasks/src/lib.rs
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.

crates/common-tasks/src/lib.rs
#[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():

crates/commonos-cli/src/main.rs
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:

~/.commonos/commonos.toml
[plugins]
enabled = ["bill", "memory", "delegate", "tasks"]

[plugins.tasks]
max_open = 10

Restart 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, settings and schema().
  • Own your tables. Don’t read the core’s or another plugin’s tables.
  • Keep schemas idempotent: CREATE … IF NOT EXISTS.
  • Use runtime sqlx::query with bound parameters.
  • Use Risk::Write for 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 warnings and the tests before you push.

Edit this page on GitHub

Esc