shuttle-rs 2026.6.0

Local-first event log CLI for agent memory, repository context, task coordination, handoffs, messaging, mesh sync, and MCP access.
Documentation
use std::net::SocketAddr;
use std::path::PathBuf;

use crate::core::{Event, Result, ShuttleError};
use crate::store::SqliteEventStore;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse};
use axum::routing::get;
use axum::{Json, Router};
use serde::Serialize;

#[derive(Clone)]
pub struct AppRuntime {
    pub store: SqliteEventStore,
    pub cwd: PathBuf,
    pub workspace_id: String,
    pub agent: String,
    pub session_id: String,
}

#[derive(Debug, Serialize)]
struct Dashboard {
    inbox: Vec<Event>,
    tasks: Vec<crate::task::TaskSummary>,
    memories: Vec<Event>,
    context: crate::context::Context,
}

pub async fn serve(runtime: AppRuntime, addr: SocketAddr) -> Result<()> {
    let app = router(runtime);
    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .map_err(|err| ShuttleError::Store(err.to_string()))?;
    axum::serve(listener, app)
        .await
        .map_err(|err| ShuttleError::Store(err.to_string()))
}

pub fn router(runtime: AppRuntime) -> Router {
    Router::new()
        .route("/", get(index))
        .route("/api/dashboard", get(dashboard))
        .route("/api/inbox", get(inbox))
        .route("/api/tasks", get(tasks))
        .route("/api/memories", get(memories))
        .route("/api/context", get(context))
        .route(
            "/mcp",
            get(mcp_health)
                .post(mcp_post)
                .delete(mcp_delete)
                .options(mcp_options),
        )
        .with_state(runtime)
}

async fn index() -> Html<&'static str> {
    Html(
        r#"<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Shuttle</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 2rem; color: #1f2937; }
    main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
    section { border: 1px solid #d1d5db; border-radius: 8px; padding: 1rem; }
    h1 { margin-top: 0; }
    pre { white-space: pre-wrap; overflow-wrap: anywhere; }
  </style>
</head>
<body>
  <h1>Shuttle</h1>
  <main id="dashboard"></main>
  <script>
    fetch('/api/dashboard').then(r => r.json()).then(data => {
      const root = document.getElementById('dashboard');
      for (const [name, value] of Object.entries(data)) {
        const section = document.createElement('section');
        section.innerHTML = `<h2>${name}</h2><pre>${JSON.stringify(value, null, 2)}</pre>`;
        root.appendChild(section);
      }
    });
  </script>
</body>
</html>"#,
    )
}

async fn dashboard(State(runtime): State<AppRuntime>) -> impl IntoResponse {
    Json(Dashboard {
        inbox: crate::message::inbox(&runtime.store, &runtime.agent)
            .await
            .unwrap_or_default(),
        tasks: crate::task::open_tasks(&runtime.store, &runtime.workspace_id, Some(20))
            .await
            .unwrap_or_default(),
        memories: crate::memory::memories(&runtime.store)
            .await
            .unwrap_or_default(),
        context: crate::context::assemble_context(
            &runtime.store,
            &runtime.cwd,
            &runtime.workspace_id,
            &runtime.agent,
        )
        .await
        .unwrap_or_else(|_| crate::context::Context {
            repo: runtime.cwd.display().to_string(),
            branch: "unknown".to_owned(),
            commit: "unknown".to_owned(),
            git_remote: None,
            dirty: false,
            dirty_files: Vec::new(),
            open_tasks: Vec::new(),
            claimed_tasks: Vec::new(),
            recent_decisions: Vec::new(),
            related_memories: Vec::new(),
            recent_messages: Vec::new(),
            pending_handoffs: Vec::new(),
            recent_completed_handoffs: Vec::new(),
            inbox: Vec::new(),
        }),
    })
}

async fn inbox(State(runtime): State<AppRuntime>) -> impl IntoResponse {
    Json(
        crate::message::inbox(&runtime.store, &runtime.agent)
            .await
            .unwrap_or_default(),
    )
}

async fn tasks(State(runtime): State<AppRuntime>) -> impl IntoResponse {
    Json(
        crate::task::open_tasks(&runtime.store, &runtime.workspace_id, Some(20))
            .await
            .unwrap_or_default(),
    )
}

async fn memories(State(runtime): State<AppRuntime>) -> impl IntoResponse {
    Json(
        crate::memory::memories(&runtime.store)
            .await
            .unwrap_or_default(),
    )
}

async fn context(State(runtime): State<AppRuntime>) -> impl IntoResponse {
    Json(
        crate::context::assemble_context(
            &runtime.store,
            &runtime.cwd,
            &runtime.workspace_id,
            &runtime.agent,
        )
        .await
        .ok(),
    )
}

async fn mcp_health() -> impl IntoResponse {
    with_cors((StatusCode::OK, "Shuttle MCP server"))
}

async fn mcp_delete() -> impl IntoResponse {
    with_cors((StatusCode::OK, "OK"))
}

async fn mcp_options() -> impl IntoResponse {
    with_cors(StatusCode::NO_CONTENT)
}

async fn mcp_post(
    State(runtime): State<AppRuntime>,
    Json(request): Json<crate::mcp::Request>,
) -> impl IntoResponse {
    let response = crate::mcp::handle_request(
        &crate::mcp::McpRuntime {
            store: runtime.store,
            cwd: runtime.cwd,
            workspace_id: runtime.workspace_id,
            agent: runtime.agent,
            session_id: runtime.session_id,
        },
        request,
    )
    .await;
    with_cors(Json(response))
}

fn with_cors(response: impl IntoResponse) -> impl IntoResponse {
    let (mut parts, body) = response.into_response().into_parts();
    parts
        .headers
        .insert("access-control-allow-origin", HeaderValue::from_static("*"));
    parts.headers.insert(
        "access-control-allow-methods",
        HeaderValue::from_static("GET,POST,DELETE,OPTIONS"),
    );
    parts.headers.insert(
        "access-control-allow-headers",
        HeaderValue::from_static("content-type,mcp-session-id"),
    );
    parts.headers.insert(
        "access-control-expose-headers",
        HeaderValue::from_static("mcp-session-id"),
    );
    (parts, body)
}