Tools
Tools are how your agent interacts with the real world — files, databases, APIs, user accounts, anything. Nimblesite gives you two ways to run them, picked per agent config:
- Client-side tools (
agent_type: "client-side"). The agent decides what to call. Your app runs it. We never touch your data, your APIs, or your secrets. Same shape as OpenAI function calling or Anthropic tool use, but persisted and multi-tenant. - Sandboxed-embodied tools (
agent_type: "sandboxed"). The agent runs against a managed per-config Linux container we provision. Your tools are real Python functions packaged into a tool bundle and uploaded to Nimblesite. The agent loop dispatches calls straight to the container; the client just sees the final text reply.
For the trade-offs between the two modes see Agent execution modes. The rest of this page is the tool authoring contract for each.
Common contract — tools_config
In both modes a tool is, to the LLM, just a name and an arguments schema. You declare the names the agent is allowed to call on the agent config:
{
"tools_config": ["read_file", "write_file", "build_site"]
}
That list is what the model sees. The difference between modes is where the function with that name lives — in your app (client-side) or in the sandbox bundle (sandboxed).
Client-side tools
When the agent decides to call a tool, you get a tool_calls array in the chat response:
{
"response": null,
"tool_calls": [
{
"id": "call_01",
"name": "write_file",
"arguments": { "path": "data/site.json", "content": "..." }
}
],
"conversation_id": "11111111-1111-1111-1111-111111111111"
}
Your app executes the tool, then posts the result back on the same conversation_id:
curl -X POST https://api.nimblesite.ai/api/v1/chat/$CONFIG_ID \
-H "X-API-Key: $NIMBLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"conversation_id": "11111111-1111-1111-1111-111111111111",
"tool_results": [
{"tool_call_id": "call_01", "name": "write_file", "content": "ok"}
]
}'
The agent picks up where it left off with the new information. The turn ends when the response carries text and no further tool_calls. If tool_calls is non-empty again, repeat.
Why your app executes, not us
- Security. Tools touch your data, your APIs, your secrets. We don't want any of those, and you don't want us to have them.
- Correctness. Your tools run in your language, your runtime, your trust boundary, with your error handling.
- Portability. You never write a "Nimblesite tool." You write a normal function in your app. Switch platforms and the function still works.
If those trade-offs don't fit — say, you want a coding agent with a real filesystem and shell — switch to sandboxed mode below.
Sandboxed-embodied tools
In sandboxed mode the agent has a per-config Linux container with a filesystem, network, and a Python tool API. Nimblesite hosts the agent loop and runs your tool code; the client just sees the final text reply.
flowchart LR
A[Your app]
L[Nimblesite agent loop]
S["Workspace container<br/>(your tools.tgz)"]
A -->|chat| L
L -->|tool call| S
S -->|result| L
L -->|text| A
- One sandbox per agent config, not per conversation. Switching conversations or starting new ones reuses the same workspace.
- Lazy-provisioned. The sandbox boots on the first tool call and idles after ~10 minutes of inactivity. The next chat resumes it automatically.
- Tool code is yours. Nimblesite ships ONE generic image. Your Python tools ride in a provisioning bundle that the image fetches at boot.
- Zero Nimblesite code changes to add or change a tool. Upload a new bundle, point the config at the new URL.
The tool bundle
A bundle is a .tgz with this layout:
flowchart TD
B["bundle.tgz"]
M["manifest.json<br/>(which tools, what dependencies)"]
T["tools/<br/>(your Python source — importable as tools.<module>)"]
Init["__init__.py"]
RF["read_file.py"]
Tpl["template/<br/>(optional — copied to /workspace on first boot)"]
B --> M
B --> T
T --> Init
T --> RF
B --> Tpl
manifest.json:
{
"schema_version": 1,
"tools": [
{ "name": "read_file", "entrypoint": "tools.read_file:run" },
{ "name": "write_file", "entrypoint": "tools.write_file:run" }
],
"requirements": ["httpx==0.27.0"],
"node_modules": []
}
Three rules:
tools[*].nameMUST matchAgentConfig.tools_config. The LLM-facing name and the dispatcher-facing name are the same string. Nimblesite rejects a config where the two diverge.entrypointismodule:functionresolved against thetools/directory (sotools.read_file:runistools/read_file.py'sdef run(...)).requirementsis plainpipsyntax. Pinned versions are installed into the workspace's venv at boot.node_modulesdoes the same withnpm install -gif you need a JS toolchain.
Writing a Python tool
Each entrypoint is a function that takes a single dict (the parsed JSON arguments the model emitted) and returns a dict with ok and content:
# tools/read_file.py
from pathlib import Path
WORKSPACE = Path("/workspace")
def run(args: dict[str, object]) -> dict[str, object]:
path_arg = args.get("path")
if not isinstance(path_arg, str):
return {"ok": False, "content": "path must be a string"}
target = (WORKSPACE / path_arg).resolve()
if WORKSPACE not in target.parents and target != WORKSPACE:
return {"ok": False, "content": "path escapes workspace"}
if not target.is_file():
return {"ok": False, "content": f"not found: {path_arg}"}
return {"ok": True, "content": target.read_text(encoding="utf-8")}
That's it. No SDK, no decorator, no framework imports. The dispatcher loads the module, calls run(args), and hands content back to the model as the tool's return value.
Conventions that pay off:
- Return early on bad input.
{"ok": False, "content": "<reason>"}is forwarded to the model, which usually self-corrects on the next turn. - Treat
/workspaceas the writable root. It's durable across conversations and survives idle-pauses. - Never
print()secrets. The container's stdout is streamed to operator logs. - No global mutable state. The dispatcher may reload your module on edit; module-level state will be lost.
Multi-modal returns
If your tool produces an image, audio, video, or a document, return a list of typed parts instead of a plain string:
def run(args: dict[str, object]) -> dict[str, object]:
return {
"ok": True,
"content": [
{"kind": "text", "content": "Here's the screenshot:"},
{"kind": "image-url", "url": "data:image/png;base64,iVBORw0K..."},
],
}
Supported kind values: text, image-url, audio-url, video-url, document-url, web-url, api-url. URLs may be http(s):// or data: (base64 inline). If the selected model doesn't natively support a given media kind, the part degrades to a plain URL string — the model still sees the reference.
Shipping the bundle
Three steps. Run them once per bundle version — not per config. The bundle is set-and-forget: upload it when you first provision an agent, then every config you create afterwards (every site, every customer) reuses the same returned url + sha256. You only come back here when the bundle's contents change.
1. Pack the bundle.
cd my-tools/
tar -czf bundle.tgz manifest.json tools/ template/
2. Upload to Nimblesite.
curl -X POST https://api.nimblesite.ai/api/v1/bundles \
-H "X-API-Key: $NIMBLE_KEY" \
-H "Content-Type: application/gzip" \
--data-binary @bundle.tgz
You get back:
{
"bundle_id": "bdl_...",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"size_bytes": 4096
}
You don't host the bundle anywhere and there is no download URL — Nimblesite stores the bytes and pushes them into the workspace machine when it's created. Cap: 50 MiB per bundle.
3. Point the config at the bundle.
curl -X POST https://api.nimblesite.ai/api/v1/configs \
-H "X-API-Key: $NIMBLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Site Editor",
"agent_type": "sandboxed",
"system_prompt": "You edit a static site under /workspace.",
"model_config": {"provider": "anthropic", "model": "claude-sonnet-4-6"},
"tools_config": ["read_file", "write_file"],
"provisioning_bundle": {
"url": "nap-bundle://bdl_...",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"workspace_secrets": {
"GITHUB_TOKEN": "ghp_..."
}
}'
The url is an opaque handle (nap-bundle://<bundle_id>) — Nimblesite resolves the bytes internally by your tenant and the SHA-256; nothing ever fetches that URL.
The next POST /api/v1/chat/{config_id} boots a sandbox with the bundle already pushed in, verifies the SHA-256, installs requirements, and dispatches the agent's tool calls to your run functions.
Creating more configs later? Skip steps 1–2 entirely — pass the same provisioning_bundle to each POST /api/v1/configs. One uploaded bundle serves any number of configs.
To change a tool: edit the Python, re-tar, re-upload, PATCH the config with the new provisioning_bundle. The next chat picks up the change on the first new sandbox. This is the exception, not the workflow — day-to-day config creation never touches the bundle endpoints.
Secrets
Anything in workspace_secrets is injected as environment variables at container boot. Read them with os.environ:
import os, httpx
def run(args: dict[str, object]) -> dict[str, object]:
token = os.environ["GITHUB_TOKEN"]
resp = httpx.get(
f"https://api.github.com/repos/{args['repo']}",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
return {"ok": resp.is_success, "content": resp.json()}
workspace_secrets is write-only — config reads never return it. Secrets never enter the agent loop's memory and never appear in chat history.
Bundle failure modes
The boot sequence is loud, not silent. If anything goes wrong you see a precise reason on GET /api/v1/configs/{id}/workspace/instances/{external_id}:
| Reason | Means |
|---|---|
bundle_fetch_failed |
Non-200 from the bundle URL. |
bundle_integrity_failed |
SHA-256 mismatch — re-upload and re-PATCH. |
bundle_manifest_invalid |
Missing fields or wrong schema_version. |
bundle_build_failed |
A manifest.build_command exited non-zero. |
bundle_entrypoint_invalid |
A tool module failed to import. |
No tools_config name silently falls through — if the LLM emits a call for a tool that isn't in the manifest, the dispatcher returns an error to the model on that turn.
Built-in tool names
Nimblesite ships a small set of built-in tool names (get_current_time, echo) wired into the reference image purely for smoke-testing the loop. In production you declare whatever names you want in tools_config and supply the function — either in your app (client-side) or in your bundle (sandboxed).
There is no tool marketplace you have to buy into. A tool is a name you made up plus either a function in your app or a Python module in your sandbox bundle.
Next
- Agent execution modes — full comparison of client-side vs. sandboxed.
- Agent configs — every field on a config, including all the
workspace_*fields. - Quickstart — ship a working agent in two HTTP calls.
Wire your tools to a real agent in minutes. Sign up free →