Tool SDK
Tool SDK is the native extension framework for Arble. Write one implementation and Arble makes it available on desktop, mobile, CLI, API, workflows and agents — no adapters, no per-surface reimplementation.
A tool defined with Tool SDK isn’t a plugin bolted onto one client. It’s registered once into Arble’s tool registry, and every runtime that can call a tool — a desktop session, a scheduled workflow, a remote agent — calls it the same way, with the same schema, the same permission gate and the same audit trail.
Why Tool SDK
MCP gives you a protocol for exposing tools to any compatible client. Tool SDK gives you a runtime built specifically for Arble, and handles everything a tool needs to behave well inside an agent loop rather than just respond to a call.
| Capability | What Arble handles for you |
|---|---|
| Typed inputs & outputs | Every call is validated against the declared schema before your code runs, and before the result reaches the agent. |
| Streaming | Progress events and partial results flow over the same channel as the final payload. |
| Authentication | API keys, OAuth tokens and local credentials resolve through the credential store — your tool never stores a secret. |
| Permissions | A tool declares what it needs; Arble asks, remembers and enforces it. |
| Memory | Tools read and write the user’s persistent memory store, so results outlive the run that produced them. |
| Resources | Files, images, database handles and URLs pass as typed values, not raw strings. |
| Cancellation & timeouts | Every call carries a cancellation signal and a deadline, enforced regardless of what your code does. |
| Tracing, logging, versioning | Every call is traced end to end and attributed to the tool version that produced it. |
None of this is scaffolding you opt into later. It’s the shape a Tool SDK tool has
from the moment you write @tool.
Quick example
Here’s a complete tool, start to finish:
from arble import tool, Context
@tool(
name="weather.current",
description="Get current conditions for a city.",
permissions=["network.http"],
)
def current_weather(city: str, units: str = "metric", ctx: Context = None) -> dict:
response = ctx.http.get(
"https://api.weather.example/v1/current",
params={"city": city, "units": units},
)
response.raise_for_status()
data = response.json()
return {
"city": city,
"temperature": data["temp"],
"condition": data["summary"],
"units": units,
}
That’s the whole tool. No manifest file, no separate schema definition, no registration step. From this one function, Arble generates:
| Artifact | Source |
|---|---|
| JSON Schema | Inferred from the function signature and type hints |
| Validation | Enforced on every call, before your code runs |
| Permission prompt | Built from the permissions list |
| CLI command | arble tools run weather.current --city Lisbon |
| REST endpoint | POST /v1/tools/weather.current |
| Streaming endpoint | POST /v1/tools/weather.current/stream |
| Registry entry | Name, version, owner and permission summary |
| Documentation | Parameter table and examples, from the docstring and type hints |
The function signature is the source of truth. Change a parameter’s type and the schema, validation, generated docs and CLI flags all change with it — there’s nothing else to keep in sync.
Supported languages
Tool SDK ships first-party libraries for four languages. Parity across them is a design constraint, not a roadmap item: the same tool built in any of the four gets the same schema inference, the same streaming model and the same registry entry shape.
Stream for streaming
Choose based on where the tool needs to run and what it needs to link against — not based on what the SDK supports, because all four support the same surface.
Tool lifecycle
A tool moves through nine stages between being an idea and being something another user’s agent can call.
- 1DefineWrite the function, its decorator and its docstring.
- 2RegisterArble reads the decorator and signature into the local registry.
- 3ValidateThe inferred schema is checked for ambiguity before anything runs.
- 4TestRun against the test harness with mock resources and permissions.
- 5PackageCode, dependencies and metadata bundle into a signed artifact.
- 6PublishPushed to a private, organization or public registry.
- 7InstallAnother environment pulls the package and its permissions.
- 8RunThe agent calls the tool like any other registry entry.
- 9ShareShared directly with a teammate or workflow, scoped by name.
Tool anatomy
Every tool is made of the same nine parts, whether it’s three lines or three hundred.
| Metadata | Name, description, owner and version — the identity the registry uses to find the tool |
|---|---|
| Input schema | The typed shape of a valid call |
| Permissions | The capabilities the tool needs, declared up front |
| Resources | Files, documents or services the tool reads or writes as typed values |
| Execution | The function body — the only part that looks like “your code” |
| Streaming | The channel for progress events and partial results |
| Output schema | The typed shape of a successful result, validated before it reaches the agent |
| Documentation | Generated from the docstring, type hints and examples |
| Registry | Everything above, indexed and queryable by other tools and workflows |
Input schemas
A tool’s input schema is inferred from its function signature by default. You widen or constrain it with standard typing constructs — Tool SDK doesn’t introduce its own type language.
Primitives — str, int, float,
bool — map directly to JSON Schema types. Objects —
a dataclass, TypedDict or Pydantic model — become nested schema objects
with their own validation. Arrays (list[T]) validate every
element, not just the container. Enums (Literal[...]) become a
closed set of allowed values. Optional fields follow the parameter’s
default — present a default and it’s optional, omit one and it’s required.
from pydantic import BaseModel, Field
from typing import Literal
class CreateIssueInput(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
body: str = ""
labels: list[str] = []
priority: Literal["low", "medium", "high"] = "medium"
assignee: str | None = None
@tool(name="github.create_issue", permissions=["network.http"])
def create_issue(input: CreateIssueInput, ctx: Context) -> dict:
...
Validation runs before your function body executes. A call with priority="urgent"
never reaches your code — the caller gets a schema error naming the exact field and
constraint that failed.
Output schemas
Output schemas work the same direction in reverse: Arble validates what your function returns before handing it to the agent, so a tool can’t silently return a shape the caller didn’t ask for.
Typed responses. A return type hint becomes the output schema, the same way input types do. Structured outputs. Nested objects and arrays are validated recursively. Streaming. A streaming tool’s output schema describes the final payload; progress and partial events have their own fixed shape. Resources. A tool can return a resource in place of raw data — the agent gets a reference it can pass to another tool, not a blob to parse.
Errors. Raise a typed exception instead of letting one propagate raw, so the agent can reason about the failure instead of seeing an opaque trace.
from arble import ToolError
class RateLimited(ToolError):
retryable = True
@tool(name="github.create_issue", permissions=["network.http"])
def create_issue(input: CreateIssueInput, ctx: Context) -> dict:
response = ctx.http.post("https://api.github.com/issues", json=input.dict())
if response.status_code == 429:
raise RateLimited("GitHub rate limit hit, retry after backoff")
response.raise_for_status()
return response.json()
Resources
A resource is anything a tool reads or writes that isn’t a plain value — Tool SDK treats these as first-class typed inputs and outputs rather than paths or blobs the tool has to manage itself.
| Resource type | Represents |
|---|---|
| File | A single file on disk, opened and closed by Arble around your call |
| Directory | A scoped view of a folder, walked or written within its permission |
| Database | A connection handle to a database Arble has been granted access to |
| Image | A typed binary resource with format and dimension metadata |
| Document | A structured document (PDF, Markdown, Word) with extraction helpers |
| Table | A typed tabular resource — rows and a schema — independent of file format |
| Web API | A named external endpoint with its authentication already resolved |
| Binary resource | Untyped bytes, for anything that doesn’t fit a more specific category |
| Streaming resource | A resource whose content arrives incrementally rather than all at once |
Declaring a parameter as Resource[File] instead of str changes
what the agent can pass you: a reference Arble already validated and scoped, not a raw path
it has to guess is valid.
Permissions
A tool declares what it needs, not how it will use it. Arble is the one that asks, remembers and enforces — the tool’s code never contains a permission check, because if the function body is running, the permission has already been granted.
@tool(
name="filesystem.write_report",
permissions=["filesystem.write:~/Reports"],
)
def write_report(path: str, content: str, ctx: Context) -> dict:
ctx.fs.write(path, content)
return {"path": path, "bytes": len(content)}
| Level | Behavior |
|---|---|
| Always allow | Granted once, applied to every future call without prompting again |
| Ask every time | Prompts on every call, regardless of prior answers |
| Never allow | Calls fail immediately with a permission error, no prompt shown |
Grants can be scoped per tool, per workflow or per
session, and are inherited down a call chain — a workflow granted
network.http doesn’t make a called tool re-prompt, but a tighter scope on
the tool is still enforced on top, never loosened by it.
Authentication
Tools that talk to external services need credentials, and Tool SDK resolves them through Arble’s credential store rather than asking you to manage secrets yourself.
API keys
ctx.secrets.get("service_name") resolves from the user’s stored
keys, and fails the call with a clear setup error if none exists.
OAuth
Declare an OAuth scope and Arble handles authorization, token storage and refresh
— your tool calls ctx.auth.token("github") and gets a valid token.
Bearer tokens
Stored once, injected on request, never logged — the same treatment as an API key.
Environment variables
Resolved through ctx.env, not os.environ, so the tool
behaves identically when Arble supplies credentials a different way in production.
Local credentials & device keychain
On desktop, Arble can resolve a credential from the OS keychain instead of its own store.
Secrets
Anything marked as a secret is redacted from logs, traces and error messages automatically.
Never accept a credential as a plain tool parameter. If a caller can pass it, it can leak
into a trace or a workflow definition — resolve credentials through ctx,
always.
Streaming
A tool that takes more than an instant should stream, not block. Make the function an async generator and yield instead of returning once.
from arble import tool, Context, Progress
@tool(name="repo.clone", permissions=["filesystem.write", "network.http"])
async def clone_repository(url: str, dest: str, ctx: Context):
yield Progress(message="Resolving repository", percent=0)
async for received, total in ctx.git.clone(url, dest):
yield Progress(
message=f"Receiving objects ({received}/{total})",
percent=int(received / total * 100),
)
yield {"path": dest, "commit": await ctx.git.head(dest)}
Progress updates carry a message and an optional percent, and render as a
live status line without polling. Partial outputs let a tool yield pieces
of its result as they’re ready. Long-running tasks stay attached to
the same call rather than needing a separate status check — the stream is the status.
Cancellation arrives on ctx.cancelled, checked cooperatively
between yields. Timeouts are enforced by Arble regardless of whether the
tool checks cancellation.
Development
arble dev runs a local loop built around fast iteration on a single tool.
Hot reload. Saving the tool file re-registers it within milliseconds.
Inspector. A live panel showing the current schema, the last five calls and
any validation errors as you type. Schema viewer. Renders the generated
JSON Schema exactly as the registry stores it. Permission preview. Shows the
exact prompt a user sees on first call. Logs. Structured, per-call logs
filterable by tool. Registry. A local-only view matching the shape of the
production registry. CLI. Every capability above is also a scriptable
command — arble tools inspect, arble tools schema,
arble tools logs.
Testing
Tool SDK’s test harness runs a tool without a live Arble session, so tests are fast and don’t depend on network state or a real agent loop.
Unit testing. arble.testing.call(tool, **kwargs) invokes a
tool directly against its schema. Mock tools. Any tool your tool calls
internally can be swapped for a mock. Replay. Record a real call once and
replay it in CI without hitting the live service again. Snapshots. Assert
that a tool’s output schema hasn’t changed unexpectedly. Permission
simulation. Run a tool as though a permission were denied. Failure
simulation. Inject a timeout or malformed response to verify error handling.
from arble.testing import call, deny_permission
def test_create_issue_success():
result = call(create_issue, input=CreateIssueInput(title="Bug"))
assert result["title"] == "Bug"
def test_create_issue_without_network_permission():
with deny_permission("network.http"):
with pytest.raises(PermissionError):
call(create_issue, input=CreateIssueInput(title="Bug"))
Publishing
arble tools publish pushes a packaged tool to a registry. Which registry
depends on who should be able to install it.
Versioning. Tools follow semantic versioning. A published version is immutable — a fix ships as a new version, never an overwrite. Signing. Every package is signed with the publisher’s key; Arble refuses an unsigned or tampered package on install.
Private registry
- Visible only to you
- For tools still in development
Organization registry
- Visible to your Arble organization
- Gated by the same role permissions
Public registry
- Visible to any Arble user
- Reviewed before listing
Built-in helpers
ctx gives every tool access to a set of helpers so common operations
don’t require a new dependency or a hand-rolled client.
| Helper | Provides |
|---|---|
| Filesystem | Scoped read/write/list, respecting the tool’s declared path permission |
| Browser | A controllable browser context for tools that need to navigate or scrape |
| Desktop | OS-level automation — window focus, screenshots, input simulation |
| Terminal | A sandboxed shell for tools that need to run a command |
| Clipboard | Read and write the system clipboard, gated behind its own permission |
| Notifications | Post a system notification the user sees outside the session |
| Memory | Read and write the user’s persistent memory store |
| Scheduler | Queue a future or recurring call to this or another tool |
| HTTP | An HTTP client with credentials, retries and tracing pre-wired |
| Database | Query a database Arble has been granted a connection to |
| Encryption | Encrypt and decrypt values using keys Arble manages |
| Search | Query the user’s indexed local content and connected sources |
Example tools
Each of these is a normal Tool SDK tool — nothing about them is privileged or built with a different API than the one described on this page.
| Filesystem | Reads, writes and searches files within a granted directory scope |
|---|---|
| Browser | Navigates, clicks and extracts content from a controlled browser session |
| Weather | Fetches current conditions and forecasts for a named location |
| GitHub | Opens issues, reviews pull requests and reads repository state |
| Slack | Posts messages and reads channel history in connected workspaces |
| Drafts, sends and searches messages through the connected inbox | |
| Calendar | Reads availability and creates events on the connected calendar |
| Database | Runs scoped queries against a granted database connection |
| Notifications | Delivers a system notification outside the current session |
| Camera | Captures a still image from a paired device’s camera |
| Microphone | Records or transcribes audio from a paired device’s microphone |
| Memory | Reads from and writes to the user’s persistent memory store |
Tool registry
Every tool Arble can call — regardless of where it came from — lands in one registry, indexed the same way.
| Source | Examples |
|---|---|
| Built-in | Filesystem, terminal, browser, memory |
| Tool SDK | Anything you build and register with @tool |
| MCP | Tools exposed by a connected MCP server |
| Desktop | OS-level capabilities on a paired desktop |
| Remote services | Tools backed by a hosted API rather than local code |
The planner that decides which tool to call for a given step reads name, description, input schema and permission requirements — the same four fields regardless of source. Adding a source of tools never means teaching the planner something new, and a tool you publish through Tool SDK is chosen exactly as readily as one Arble shipped with. See MCP server for how connectors join the same registry.
Performance
Cold start. A tool’s first call in a session pays a one-time cost to
load its code and resolve permissions; every call after reuses the warm instance.
Caching. Declare a result as cacheable with a TTL —
@tool(cache_ttl=60) — and repeated identical calls are served from cache.
Streaming. Lets Arble start planning the next step before the current one
fully completes, when the plan doesn’t depend on the final value. Memory.
Write summaries, not raw payloads — a dumped API response bloats every future context
that reads it back. Latency. The registry tracks p50/p95 per tool and
surfaces regressions before a user reports them. Retries. Mark errors
retryable=True and Arble retries with backoff automatically. Timeouts.
Set a tool-level default with @tool(timeout=30); callers can request a shorter
deadline, never a longer one.
Security
Sandbox
Tool code runs in an isolated process with no ambient access to the filesystem, network or other tools’ data — only what its declared permissions and resources explicitly grant.
Permissions
Every capability is opt-in and declared up front. There is no implicit access a tool can fall back on.
Audit logs
Every call — inputs, the permission grant it ran under, and its outcome — is logged and queryable.
Least privilege
Scope permissions as narrowly as the tool functionally needs —
filesystem.write:~/Reports, not filesystem.write:~.
Encryption
Secrets and credentials are encrypted at rest and never appear in plaintext in logs, traces or registry entries.
Revocation
Revoking a permission takes effect on the next call, immediately, with no reinstall or restart required.
Best practices
- Keep a tool’s scope narrow — one clear job, not a dozen optional modes.
- Name tools
namespace.action(github.create_issue), so the registry stays legible at scale. - Write the docstring for the agent, not for yourself — it’s what the planner reads.
- Declare the narrowest permission that works, scoped to specific paths or hosts.
- Never accept credentials as parameters — resolve them through
ctx.secretsorctx.auth. - Prefer typed models over loose
dictparameters — the schema is only as good as the types. - Stream anything that takes more than a couple of seconds.
- Check
ctx.cancelledbetween yields in any streaming tool. - Raise typed
ToolErrorsubclasses instead of letting exceptions propagate raw. - Mark genuinely retryable errors
retryable=True, never ones that write data non-idempotently. - Write memory entries as summaries, not raw dumps.
- Version deliberately — a breaking schema change is a major version, not a patch.
- Test with permission simulation, not just happy-path calls.
- Return resources instead of raw paths or blobs for files, images or documents.
- Keep the function body free of permission or credential checks — that’s what the decorator is for.
Reference
- Decorators —
@tool, parameters, defaults - Schemas — type inference rules, explicit overrides
- Resources — full resource type catalog and helpers
- Permissions — scope syntax, grant levels, inheritance
- Streaming —
Progress, partial results, cancellation - Memory — read/write API, retention, summarization
- Authentication —
ctx.secrets,ctx.auth, OAuth setup - CLI — full
arble toolscommand reference - Publishing — package format, signing, registry configuration
- Testing —
arble.testingAPI reference - API — REST and streaming endpoints for published tools
FAQ
Do I need MCP if I’m using Tool SDK?
No. They solve different problems. MCP connects Arble to a server someone else runs; Tool SDK is how you build a tool that runs as a native part of Arble itself. Many setups use both.
Can a Tool SDK tool call an MCP tool?
Yes. From the registry’s perspective they’re both just tools — your
function can call one through the same ctx interface it uses for any
built-in helper.
What happens if I change a tool’s input schema after publishing?
A backward-compatible change ships as a patch. A breaking change needs a new major version — the old version keeps working for anyone pinned to it.
How does Arble decide which tool to call for a given task?
The planner reads each candidate’s name, description and schema from the registry and picks based on fit, regardless of whether the tool is built-in, SDK or MCP.
Can I test a tool without a full Arble session running?
Yes — the arble.testing harness calls a tool directly against its
schema and mocked context, with no live session required.
Do streaming tools need a different permission model?
No. Permissions are declared once on the tool and apply to the whole call, streamed or not.
Where are secrets actually stored?
In Arble’s encrypted credential store, resolved into your tool at call time
through ctx.secrets or ctx.auth. Your code never touches the
storage layer.
Can I publish a tool privately within my company only?
Yes — the organization registry is visible only to members of your Arble organization.
What’s the difference between permissions and a resource scope?
A permission is a capability (“this tool can write files”); a resource scope narrows where. Most filesystem and network permissions carry a scope as part of the same declaration.
Is there a limit on how long a streaming tool can run?
Every tool has a deadline, defaulting to 30 seconds and overridable with
@tool(timeout=...). A streaming tool that needs longer should say so
explicitly rather than relying on the default.