/docs / apps / sidecar

App Sidecar

The sidecar is a native binary that runs alongside your app. It serves API endpoints over gRPC via a Unix socket, and Nebo proxies all requests from /apps/{{id}}/ api/* to the sidecar. Use it for CRUD APIs, complex data processing, or any logic that doesn't belong in the frontend.

When You Need a Sidecar

ScenarioSidecar?Alternative
CRUD API with local dataYes--
Complex data processingYes--
External API integrationMaybenebo.fetch + HTTP proxy
AI-only featuresNonebo.agents.invoke()
Simple persistenceNonebo.storage

How the SDK Reaches the Sidecar

The frontend never talks to the sidecar directly. Every request flows through Nebo's proxy, which converts HTTP to gRPC:

Frontend                  Nebo Server                    Sidecar
--------                  -----------                    -------
nebo.fetch('/projects')
  |
  +- SDK builds URL:
  |  {base}/api/v1/apps/{id}/api/projects
  |
  +---- HTTP GET -------->  proxy_to_sidecar()
                              |
                              +- Extracts method, path,
                              |  query, headers, body
                              |
                              +- Connects to Unix socket
                              |  {app_dir}/{id}.sock
                              |
                              +---- gRPC HandleRequest -->  UIService
                                    HttpRequest {              |
                                      method: "GET"            +- Routes path
                                      path: "projects"         |  to handler
                                      query: ""                |
                                      headers: {...}           +- Processes
                                      body: []                 |  request
                                    }                          |
                                                               +- Returns
                              <---- HttpResponse ----------------+
                              HttpResponse {
                                status_code: 200
                                headers: {"content-type": "application/json"}
                                body: [{"id":"abc","name":"My Project",...}]
                              }
                              |
  <---- HTTP 200 -------------+
  JSON body returned to caller
Key detail: The path field the sidecar receives is relative -- stripped of the /apps/{{id}}/ api/ prefix. If the frontend calls nebo.fetch('/projects/abc'), the sidecar sees path: "projects/abc".

Calling the Sidecar from Frontend Code

Use nebo.fetch() -- it mirrors the native fetch() API but auto-routes relative URLs to your sidecar:

// GET -- list resources
const resp = await nebo.fetch('/projects');
const projects = await resp.json();

// POST -- create a resource
const resp = await nebo.fetch('/projects', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'New Project' })
});

// PUT -- update a resource
await nebo.fetch('/projects/abc', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Updated Name' })
});

// DELETE -- remove a resource
await nebo.fetch('/projects/abc', { method: 'DELETE' });

// Query strings work naturally
const resp = await nebo.fetch('/documents?project_id=abc&format=pdf');

nebo.fetch() returns a standard Response object -- use .json(), .text(), .blob(), check .ok, .status, etc. exactly as you would with fetch().

  • Relative URLs (no scheme) -- routed to your sidecar via {base}/api/v1/apps/{id}/api{path}
  • Absolute URLs (https://...) -- routed through Nebo's CORS-free HTTP proxy

The gRPC Contract

Your sidecar must implement the UIService gRPC service defined in proto/apps/v0/ui.proto:

service UIService {
  rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Configure(SettingsMap) returns (Empty);
  rpc HandleRequest(HttpRequest) returns (HttpResponse);
}

message HttpRequest {
  string method = 1;               // GET, POST, PUT, DELETE, etc.
  string path = 2;                 // Path relative to /apps/{id}/api/
  string query = 3;                // Raw query string (e.g. "page=1&size=10")
  map<string, string> headers = 4; // Request headers
  bytes body = 5;                  // Request body (empty for GET/HEAD)
}

message HttpResponse {
  int32 status_code = 1;           // HTTP status code (200, 404, 500, etc.)
  map<string, string> headers = 2; // Response headers
  bytes body = 3;                  // Response body
}
RPCPurpose
HealthCheckReturn healthy: true, version, and name. Called by Nebo to verify the sidecar is alive.
HandleRequestProcess an HTTP request and return an HTTP response. This is where all your app logic lives.
ConfigureReceive settings updates. Can be a no-op if your app doesn't use configuration.

Handling Requests

Your sidecar receives every proxied request as a gRPC HandleRequest call. The typical pattern is to split path segments and match:

use proto::{HttpRequest, HttpResponse};

async fn handle_http(&self, method: &str, path: &str, query: &str, body: &[u8]) -> HttpResponse {
    let parts: Vec<&str> = path.trim_start_matches('/')
        .split('/')
        .filter(|s| !s.is_empty())
        .collect();

    match (method, parts.as_slice()) {
        // GET /projects -- list all projects
        ("GET", &["projects"]) => {
            let projects = self.state.list_projects().await;
            json_response(200, &projects)
        }
        // POST /projects -- create a project
        ("POST", &["projects"]) => {
            let req: CreateRequest = serde_json::from_slice(body)?;
            let project = self.state.create_project(req).await;
            json_response(201, &project)
        }
        // GET /projects/{id} -- get one project
        ("GET", &["projects", id]) => {
            match self.state.get_project(id).await {
                Some(p) => json_response(200, &p),
                None => json_response(404, &json!({  "error": "not found" })),
            }
        }
        _ => json_response(404, &json!({ "error": "not found" }))
    }
}

fn json_response<T: serde::Serialize>(status: i32, data: &T) -> HttpResponse {
    HttpResponse {
        status_code: status,
        headers: HashMap::from([("content-type".into(), "application/json".into())]),
        body: serde_json::to_vec(data).unwrap_or_default(),
    }
}

Sidecar Startup

Your sidecar binary must:

  • Read $NEBO_APP_SOCK for the socket path
  • Read $NEBO_APP_DATA for the writable data directory
  • Bind a Unix socket at the $NEBO_APP_SOCK path
  • Serve the UIService gRPC service on that socket
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let sock_path = std::env::var("NEBO_APP_SOCK")
        .unwrap_or_else(|_| "/tmp/my-app.sock".into());
    let data_dir = std::env::var("NEBO_APP_DATA")
        .unwrap_or_else(|_| "data".into());

    // Clean stale socket
    let _ = std::fs::remove_file(&sock_path);

    let service = MyAppService::new(&data_dir);

    let uds = tokio::net::UnixListener::bind(&sock_path)?;
    let uds_stream = tokio_stream::wrappers::UnixListenerStream::new(uds);

    tonic::transport::Server::builder()
        .add_service(UiServiceServer::new(service))
        .serve_with_incoming(uds_stream)
        .await?;

    Ok(())
}

The sidecar must create the Unix socket within the startup timeout (default 10 seconds, max 120). If the socket doesn't appear, the launch fails and the next request retries.

Environment Variables

Your sidecar runs in a sandboxed environment with only these variables:

VariableExampleDescription
NEBO_APP_IDdeal-trackerAgent identifier
NEBO_APP_NAMEDeal TrackerDisplay name
NEBO_APP_VERSION1.0.0Manifest version
NEBO_APP_DIR/Users/me/.nebo/user/agents/deal-trackerApp root directory
NEBO_APP_SOCK...deal-tracker/deal-tracker.sockUnix socket path
NEBO_APP_DATA<data_dir>/appdata/agents/deal-trackerWritable data directory (survives upgrades)
PATHsystem pathStandard path
HOMEuser homeHome directory
API keys, database URLs, and secrets are never passed to sidecars.

Lifecycle

Nebo owns the full lifecycle of every sidecar. You do not need to manage sidecar lifetime yourself.

  • Auto-launch -- the first API request to a sidecar triggers launch if it's not already running
  • Restore on server restart -- previously-running sidecars are detected and auto-relaunched when the server starts
  • Health checks -- every 15 seconds via HealthCheck RPC
  • Crash recovery -- auto-restart with exponential backoff (10s, 20s, 40s, 80s, 160s, max 5min). Maximum 5 crash restarts per hour
  • Binary hot-reload -- if the binary on disk changes (e.g. you rebuild), Nebo gracefully stops the running process, unregisters old tools, restarts, and re-discovers tools. No backoff, no limit, immediate restart
  • Shutdown -- when Nebo shuts down (SIGTERM, Ctrl+C, or app quit), it sends SIGTERM to every running sidecar and waits for exit

Sidecars should handle SIGTERM gracefully -- flush data, close connections, then exit. Nebo broadcasts app_crashed on crash and app_restarted after recovery (includes reason: "binary_changed" for hot-reloads).

Tool Discovery (GET /_tools)

When a sidecar starts, Nebo queries GET /_tools to discover what API endpoints the sidecar exposes. If the sidecar responds with tool definitions, Nebo registers them as LLM-callable tools -- the agent can then call your sidecar's API directly during conversations.

This is optional. If your sidecar doesn't implement /_tools, the agent can still be used via the chat embed and SDK, but it won't be able to call your API endpoints as tools during LLM reasoning.

Implementing /_tools

// In your handle_http match block:
("GET", &["_tools"]) => {
    let tools = serde_json::json!([
        {
            "name": "list_projects",
            "description": "List all projects for the current user",
            "method": "GET",
            "path": "/projects"
        },
        {
            "name": "create_project",
            "description": "Create a new project with a name and optional description",
            "method": "POST",
            "path": "/projects",
            "input_schema": {
                "type": "object",
                "properties": {
                    "name": { "type": "string", "description": "Project name" },
                    "description": { "type": "string", "description": "Optional description" }
                },
                "required": ["name"]
            }
        },
        {
            "name": "get_project",
            "description": "Get a single project by ID",
            "method": "GET",
            "path": "/projects/{id}"
        }
    ]);
    json_response(200, &tools)
}

Tool Definition Schema

FieldTypeRequiredDescription
namestringYesAction name the LLM uses (e.g. "list_projects")
descriptionstringYesWhat this action does -- shown to the LLM
methodstringYesHTTP method: GET, POST, PUT, DELETE
pathstringYesPath relative to sidecar root (e.g. "/projects/{id}")
input_schemaobjectNoJSON Schema for the request body. Omit for GET/DELETE.

Path Parameters

Use {param} placeholders in the path. When the LLM calls the tool, Nebo extracts matching keys from the input and substitutes them. For GET requests, non-path input parameters are sent as query strings. For POST/PUT requests, they are sent as the JSON body.

How It Works

  • Sidecar starts -- Nebo calls GET /_tools via gRPC HandleRequest
  • Sidecar returns 200 with JSON array of tool definitions
  • Nebo registers each tool per-agent in the global tool registry -- the LLM sees list_projects(...), get_document(id: "..."), etc. directly
  • Tool discovery runs again automatically when the sidecar restarts (crash recovery or binary hot-reload)
  • On sidecar shutdown, all registered tools are cleaned up
Tools are not enough on their own. The LLM knows it can call list_projects, but it doesn't know when or how to use it effectively. That's what skills are for -- see the Apps Overview for bundling skills with your app.

Tool Scoping

By default, all sidecar tools and skills are available in every embed chat context. Tool scoping lets you restrict which tools, skills, and plugins are active based on where the chat is mounted.

Defining Scopes in agent.json

{
  "skills": ["skills/workspace-management", "skills/document-editing"],
  "requires": { "plugins": ["gws"] },
  "scopes": {
    "editor": {
      "tools": ["get_document", "update_document", "get_comments", "add_comment"],
      "skills": ["skills/document-editing"]
    },
    "projects": {
      "tools": ["list_projects", "create_project", "search_documents"],
      "skills": ["skills/workspace-management"],
      "plugins": ["gws"]
    }
  }
}

Each scope declares:

  • tools -- which sidecar tool names are active (subset of what /_tools returns)
  • skills -- which skill refs to load into the prompt (subset of top-level skills array)
  • plugins -- additional plugins to pre-activate (merged with global requires.plugins)

Using Scopes from the SDK

// Document editing view -- only document tools + skills
nebo.chat.mount(container, {
  contextId: doc.id,
  scope: 'editor'
});

// Project overview -- different tools + skills + plugins
nebo.chat.mount(container, {
  contextId: 'projects',
  scope: 'projects'
});

// No scope -- all tools/skills/plugins available (default)
nebo.chat.mount(container, {
  contextId: 'general'
});

Scope Behavior

SDK scopeToolsSkillsPlugins
Not setAll sidecar toolsAll agent.json skillsAll requires.plugins
"editor"Only scope.toolsOnly scope.skillsrequires.plugins + scope.plugins
Unknown nameWarning logged, falls back to "not set"SameSame

Core system tools (memory, scheduling, etc.) are always available regardless of scope. The scope only controls your app's sidecar tools.

Why scope? 18 tools x ~500 tokens each = 9K tokens. Scoping to 4 tools saves ~7K tokens per request, improves LLM tool selection accuracy, and gives publishers control over exactly what the agent can do in each context.

Data Persistence

The sidecar owns its own data in $NEBO_APP_DATA. Common approaches:

  • JSON file -- simplest. Load at startup, save after mutations. Works well for small datasets (< 10MB).
  • SQLite -- use for structured data, queries, or anything beyond trivial CRUD.
  • File store -- store blobs (uploaded documents, images) as files in the data directory.

The data directory is physically separated from the code directory -- it lives at <data_dir>/appdata/agents/{{id}}/, not inside the app's code tree. This means you can safely upgrade or reinstall the app binary without touching your data. The data directory survives sidecar restarts, app updates, reinstalls, and Nebo upgrades.

Binary Location

Place your compiled binary in one of these locations (checked in order):

  • {app_dir}/binary -- single named file
  • {app_dir}/app -- single named file
  • {app_dir}/bin/ -- first file in directory
  • {app_dir}/sidecar/target/release/ -- first executable (Rust dev builds)

For production distribution, use bin/. For development, sidecar/target/release/ is detected automatically. Symlinks are followed, so bin/my-app can symlink to sidecar/target/release/my-app.

Development Workflow

1. Create the directory

$NEBO_DATA below is the platform-native Nebo data directory -- ~/Library/Application Support/Nebo on macOS, %APPDATA%\Nebo on Windows, ~/.local/share/nebo on Linux (override with NEBO_HOME).

mkdir -p "$NEBO_DATA/user/agents/my-app/ui"

2. Symlink from source (recommended)

# Work from a source repo, symlink into Nebo
ln -s /path/to/my-app "$NEBO_DATA/user/agents/my-app"

The filesystem watcher detects new symlinks automatically -- the app appears in the Apps tab within seconds. Changes to your source directory take effect immediately.

3. Build and iterate

  • Frontend changes -- edit ui/ files, refresh the app window
  • Sidecar changes -- rebuild the binary; Nebo detects the changed file and restarts the sidecar automatically (within 15 seconds)
  • Agent changes -- edit AGENT.md, changes are picked up on next invocation

Publishing Apps with Sidecars

Upload a binary for each platform you support. Apps without a sidecar (pure frontend) don't need binary uploads.

PlatformArchitecture
darwin-arm64macOS Apple Silicon
darwin-amd64macOS Intel
linux-arm64Linux ARM
linux-amd64Linux x86_64
windows-arm64Windows ARM
windows-amd64Windows x86_64

Via MCP Server

1. developer(resource: account, action: select, id: "your-dev-account-id")
2. agent(action: create, name: "deal-tracker", manifestContent: "# Deal Tracker\n...")
3. agent(action: binary-token, id: "AGENT_ID")
4. Upload binary via returned curl command (per platform)
5. agent(action: submit, id: "AGENT_ID", version: "1.0.0")

See the Publishing via MCP guide for the full workflow.

Logging

Sidecar stdout and stderr are captured to {app_dir}/data/sidecar.log (append mode). Check this file when debugging startup issues.