Push a folder. Invoke via API. Agents calling agents.
curl -fsSL https://sbgnt.com/install.sh | bash
# 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"}'
| Level | Model | Max Iterations | Use Case |
|---|---|---|---|
fast | Haiku | 30 | Quick lookups |
balanced | Sonnet | 45 | Standard work |
comprehensive | Opus | 60 | Deep research |
my-agent/
├── agent.toml # name, route, description
├── system.md # system prompt
├── tools.star # starlark tool functions
└── permissions.toml # allowed domains/methods (optional)
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"]
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.
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)
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
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.
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
A folder with 4 files. Push it, invoke it. No build step, no framework.
Define tools as starlark functions. Sandboxed, inspectable, safe.
OAuth-managed credentials. Your tools just call fetch() — auth is handled.
Every invocation is stored and resumable. Multi-turn conversations with subagents.