sbgnt / subagent

Push a folder. Invoke via API. Agents calling agents.

Install

curl -fsSL https://sbgnt.com/install.sh | bash

Quick Start

# 1. Log in
sbgnt login

# 2. Create a subagent
mkdir my-agent && cd my-agent

cat > agent.toml << 'EOF'
name = "my-agent"
route = "my-agent"
description = "A helpful subagent"
EOF

cat > system.md << 'EOF'
You are a helpful assistant. Use the tools available to answer questions.
EOF

cat > tools.star << 'EOF'
TOOLS = {
    "hello": "Say hello to someone",
}

def hello(name):
    return "Hello, " + name + "!"
EOF

# 3. Push it
sbgnt push .

# 4. Invoke it
sbgnt ask my-agent "say hello to world"

# Or via API:
curl -X POST https://sbgnt.com/a/my-agent \
  -H "Authorization: Bearer $SBGNT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "say hello to world", "effort": "fast"}'

Effort Levels

LevelModelMax IterationsUse Case
fastHaiku30Quick lookups
balancedSonnet45Standard work
comprehensiveOpus60Deep research

Subagent Structure

my-agent/
├── agent.toml          # name, route, description
├── system.md           # system prompt
├── tools.star          # starlark tool functions
└── permissions.toml    # allowed domains/methods (optional)

permissions.toml

Controls what your agent's fetch() and ask() calls can access. Without this file, your agent cannot make HTTP requests or call other agents.

# Allow fetch() to these domains
allowed_domains = ["api.example.com", "httpbin.org"]
allowed_methods = ["GET", "POST"]

# Fine-grained rules per domain
[[rules]]
domain = "api.github.com"
methods = ["GET"]
allow_paths = ["/repos/**", "/users/**"]
deny_paths = ["/repos/*/actions/**"]

# OAuth connections — auto-injects auth headers for these domains
[[connections]]
name = "gmail"
domains = ["gmail.googleapis.com"]

# Allow this agent to call other agents via ask()
allowed_agents = ["my-other-agent"]

Agent-to-Agent Calls (ask)

Agents can call other agents using the built-in ask() function. This is the recommended pattern for composing agents — do not create API keys for agent-to-agent calls.

# tools.star — define a tool that delegates to another agent
TOOLS = {
    "research": "Delegate a research task to a specialist agent",
}

def research(query, effort="balanced"):
    # ask() is a built-in that calls another deployed agent
    return ask(route="my-research-agent", query=query, effort=effort)

To enable ask(), list the allowed agent routes in permissions.toml:

# permissions.toml
allowed_agents = ["my-research-agent", "gmail-agent"]

The ask() builtin runs the target agent as a sub-session (up to 3 levels deep) and returns its text response. The calling agent's user context is inherited, so the sub-agent sees the same connections and permissions.

Starlark Builtins

These functions are available in every tools.star file:

# ── HTTP ──
# fetch(url, method="GET", body="", headers={})
# Makes an HTTP request. Requires matching permissions.toml entry.
# Returns a struct with .status (int), .headers (dict), .body (string).
resp = fetch(url="https://api.example.com/data", method="POST",
             body='{"key": "value"}',
             headers={"Accept": "application/json"})
print(resp.status)   # 200
print(resp.body)     # '{"result": ...}'

# ── Sub-agent calls ──
# ask(route, query, effort="balanced")
# Calls another deployed agent. Requires allowed_agents in permissions.toml.
# Returns the agent's text response as a string.
result = ask(route="my-other-agent", query="summarize this", effort="fast")

# ── URL encoding ──
# urlencode(s)
# Percent-encodes a string for use in URL query parameters.
q = urlencode("hello world & more")  # "hello+world+%26+more"
url = "https://api.example.com/search?q=" + q

# ── JSON ──
# json_decode(s)
# Parses a JSON string into starlark values (dicts, lists, strings, ints, bools, None).
data = json_decode('{"users": [{"name": "Alice"}], "count": 1}')
print(data["users"][0]["name"])  # "Alice"
print(data["count"])             # 1

# json_encode(v)
# Serializes a starlark value (dict, list, string, int, float, bool, None) to JSON.
s = json_encode({"key": "value", "nums": [1, 2, 3]})
print(s)  # '{"key":"value","nums":[1,2,3]}'

# ── Output ──
# print(...)
# Prints to the tool's output (visible in session logs, not in the tool result).
print("debug:", some_value)

Starlark Runtime

Tools are written in Starlark, a Python-like language designed for configuration and embedded scripting.

# Execution model
- Each tool call runs in a fresh starlark thread
- Module-level code (outside functions) runs once when the agent is loaded
- Module-level variables ARE shared across tool calls within a session
- No imports — all code must be in a single tools.star file
- Functions starting with _ are private (not exposed as tools)

# Timeouts and limits
- Tool execution timeout: 30 seconds (5 minutes if ask() is enabled)
- HTTP response body limit: 1 MB
- Test execution timeout: 30 seconds

# Available types
- Strings, ints, floats, bools, None
- Lists, dicts, tuples, sets
- Structs (returned by fetch())

# What's NOT available
- No file I/O, no network access except via fetch()
- No os, sys, or subprocess
- No third-party imports
- No threading or async

# Conventions
- TOOLS dict maps function names to descriptions (required)
- Parameters are always strings (from the model's JSON input)
- Return a string from each tool function

Testing

Test your tools locally before deploying with sbgnt test <dir>. Create a test_tools.star file alongside your tools.star:

# test_tools.star — all test_* functions run automatically

def test_hello():
    result = hello(name="world")
    assert_eq(got=result, want="Hello, world!")

def test_search_with_mock():
    # Queue a mock HTTP response for fetch()
    set_mock_responses([
        mock_fetch(status=200, body='{"results": [{"title": "Example"}]}')
    ])
    result = search(query="test")
    assert_contains(haystack=result, needle="Example")

def test_error_handling():
    set_mock_responses([
        mock_fetch(status=404, body='{"error": "not found"}')
    ])
    result = search(query="missing")
    assert_contains(haystack=result, needle="not found")
$ sbgnt test ./my-agent
PASS  test_hello
PASS  test_search_with_mock
PASS  test_error_handling

3 passed, 0 failed

Test helpers available in test_tools.star:

# Mock responses
mock_fetch(status, body, headers={})       # Create a mock fetch response struct
set_mock_responses([resp1, resp2, ...])    # Queue responses for fetch() calls
set_mock_ask_responses(["resp1", ...])     # Queue responses for ask() calls
reset_mocks()                              # Clear all mock state

# Assertions
assert_eq(got, want, msg="")               # Assert two values are equal
assert_contains(haystack, needle, msg="")  # Assert substring match

# All builtins (fetch, ask, urlencode, json_decode, json_encode, print)
# are also available — fetch/ask use mock responses instead of real HTTP.

API

POST /a/{route}               Invoke a subagent
  Body: {"query": "...", "effort": "balanced", "session_id": "..."}

POST /api/agents              Push a subagent
GET  /api/agents              List subagents
GET  /api/agents/{route}      Get subagent details
DELETE /api/agents/{route}    Delete a subagent

GET  /api/sessions/{id}       Get session history

POST /api/connections         Add a connection
GET  /api/connections         List connections
DELETE /api/connections/{n}   Remove a connection

POST /api/tokens              Create API token
GET  /api/tokens              List tokens
DELETE /api/tokens/{id}       Revoke token

Concepts

Subagents

A folder with 4 files. Push it, invoke it. No build step, no framework.

Starlark Tools

Define tools as starlark functions. Sandboxed, inspectable, safe.

Connections

OAuth-managed credentials. Your tools just call fetch() — auth is handled.

Sessions

Every invocation is stored and resumable. Multi-turn conversations with subagents.