ClawSeed External Tool Protocol (CETP) v2 Experimental Specification¶
Status: Experimental. The ClawSeed Android SDK implements a v2 Consumer and Provider base class, but the wire protocol may still change before its stable release. Production integrations should retain a read-only CETP v1 compatibility layer.
CETP v2 keeps the low integration cost of Android ContentProvider while extending CETP from a
read-only data bridge into a cross-app tool protocol that can safely carry side effects, long-running
operations, and large results. A v2 Consumer must continue to support v1. A Provider may implement
v1, v2, or both.
The terms MUST, MUST NOT, SHOULD, and MAY express requirements, prohibitions, recommendations, and optional behavior respectively.
1. Goals¶
- Declare each tool's side effects, risk, idempotency, authorization scopes, and confirmation policy.
- Make the Provider the final enforcer of authorization, user confirmation, and mutation deduplication.
- Negotiate versions and capabilities explicitly instead of comparing one integer.
- Support deadlines, cancellation, asynchronous operations, and results too large to inline through Binder.
- Keep v1 Provider and Consumer behavior stable and provide a dual-stack migration path.
- Retain JSON Schema for tool input and output without imposing shared business schemas across Providers.
Non-Goals¶
- This binding does not support transports outside Android. Other platforms may reuse its semantics but require a separate transport binding.
- It does not provide transactions across Providers or atomicity across multiple tool calls.
- It does not consider the Consumer, LLM, or Provider trusted by default.
- Consumer UI cannot substitute for Provider-side confirmation of high-risk operations.
- Unattended authorization of high-risk actions is outside v2; scheduled trading and similar features require a separate specification.
2. Compatible Discovery and Negotiation¶
2.1 Manifest Declaration¶
v2 retains the v1 discovery action and authority. It adds com.clawseed.tools.versions, containing a
comma-separated set of supported major versions.
A Provider supporting both v1 and v2 MUST keep the legacy version=1 field so v1 Consumers can still
discover it:
<service
android:name=".cetp.ToolProviderService"
android:exported="true">
<intent-filter>
<action android:name="com.clawseed.action.TOOL_PROVIDER" />
</intent-filter>
<meta-data android:name="com.clawseed.tools.authority"
android:value="com.example.app.clawseed.tools" />
<meta-data android:name="com.clawseed.tools.version"
android:value="1" />
<meta-data android:name="com.clawseed.tools.versions"
android:value="1,2" />
</service>
- If only
versionexists, a v2 Consumer treats it as the sole supported version. - If both fields exist,
versionsis authoritative andversionserves legacy Consumers only. - A dual-stack Provider MUST NOT return side-effecting tools from its v1
list_toolscall. - A v2-only Provider uses
version=2andversions="2"; legacy Consumers will ignore it. - A Consumer MUST verify that the discovered Service and authority's
ContentProviderbelong to the same package.
2.2 negotiate¶
After the manifest indicates possible v2 support, the Consumer MUST call negotiate first. Request extras:
| Key | Bundle type | Requirement |
|---|---|---|
consumer_versions |
int[] |
Required, supported major versions in descending order |
consumer_capabilities |
String |
Required JSON string array |
A successful response retains Bundle status="success" and a JSON string in data:
{
"selected_version": 2,
"provider_id": "com.example.app",
"provider_name": "Example App",
"session_token": "opaque-provider-issued-token",
"expires_at": "2026-09-02T12:00:00Z",
"capabilities": [
"async_operations",
"cancellation",
"large_results"
],
"limits": {
"max_request_bytes": 65536,
"max_inline_result_bytes": 262144,
"max_concurrent_requests": 4,
"idempotency_window_seconds": 86400
}
}
The Provider selects the highest common version and returns only optional capabilities supported by both
sides. The session_token:
- MUST be opaque and unpredictable, with a maximum length of 256 bytes;
- MUST be bound to the calling UID, selected version, and capability set;
- MAY expire and MAY become invalid after Provider process restart; and
- MUST accompany subsequent v2 calls, which return
NEGOTIATION_REQUIREDwhen it is invalid.
If no common version exists, the Provider returns UNSUPPORTED_VERSION. It MUST NOT silently interpret
the request as another version.
limits MUST satisfy max_request_bytes >= 65536, max_inline_result_bytes >= 262144,
max_concurrent_requests >= 1, and idempotency_window_seconds >= 86400. Both sides SHOULD retain
independent local resource limits. Negotiated values express protocol allowances and do not require a
Consumer to allocate the same amount of memory eagerly.
2.3 v2 Core and Optional Capabilities¶
Every v2 implementation MUST support tool annotations, output schemas, scoped authorization, structured errors, Provider resolutions, and idempotent mutation deduplication. The following capabilities are negotiated:
| Capability | Meaning |
|---|---|
async_operations |
execute_tool may return a background operation |
cancellation |
cancel_operation is available |
large_results |
Large results use ParcelFileDescriptor |
A Consumer MUST NOT invoke an unnegotiated optional capability. A Provider MUST NOT change capability
semantics during a negotiated session; it SHOULD invalidate the old session_token and require a new
negotiation when capabilities change.
3. Provider and Tool Descriptors¶
3.1 Stable Identity¶
provider_idMUST be stable and defaults to the Android package name. A custom value MUST remain within that package's namespace.- Tool
nameMUST match[a-z][a-z0-9_]{0,63}and remain stable and unique within one Provider. - Local display names may change, but routing MUST use
(provider_id, tool_name)and MUST NOT depend on scan order or application labels.
3.2 get_provider_info¶
A v2 Provider MUST return stable identity, description, and a scope catalog:
{
"provider_id": "com.example.app",
"provider_name": "Example App",
"description": "Manage price alerts and watchlists",
"schema_dialect": "https://json-schema.org/draft/2020-12/schema",
"scopes": [
{
"name": "alerts.write",
"title": "Manage price alerts",
"description": "Create, update, and delete alerts",
"access": "write",
"sensitivity": "financial"
}
]
}
A scope name MUST be stable and match [a-z][a-z0-9_.-]{0,127}. access is read or write;
sensitivity is public, personal, financial, health, device, or other. These fields help the
Consumer present a grant, while the Provider remains responsible for authorization decisions.
schema_dialect applies to every input and output schema in the negotiated session. v2 defaults to JSON
Schema Draft 2020-12. A schema MUST NOT reference network resources; $ref may resolve only to fragments
within the same schema document. Both sides MUST ignore unknown descriptor fields, but MUST NOT ignore a
missing required field or an unknown security enum value.
3.3 list_tools¶
v2 retains the list_tools method and requires these extras:
| Key | Bundle type | Requirement |
|---|---|---|
protocol_version |
Int |
Must be 2 |
session_token |
String |
Token returned by negotiate |
Example response:
{
"revision": "tools-42",
"tools": [
{
"name": "create_alert",
"title": "Create price alert",
"description": "Create a price alert for a security",
"input_schema": {
"type": "object",
"properties": {
"symbol": {"type": "string"},
"price": {"type": "number", "exclusiveMinimum": 0}
},
"required": ["symbol", "price"],
"additionalProperties": false
},
"output_schema": {
"type": "object",
"properties": {"alert_id": {"type": "string"}},
"required": ["alert_id"]
},
"scopes": ["alerts.write"],
"annotations": {
"effect": "create",
"risk": "moderate",
"destructive": false,
"idempotent": false,
"open_world": false,
"confirmation": "provider_policy",
"execution": "sync_or_async"
}
}
]
}
revision MUST change whenever the tool set, schema, or security semantics change. A Consumer may cache
a list with the same revision but MUST check the revision at least once per new negotiation.
3.4 Tool Annotations¶
| Field | Allowed values | Meaning |
|---|---|---|
effect |
read, create, update, delete, transaction |
Primary effect on persistent state or the real world |
risk |
low, moderate, high |
Impact of failure or accidental invocation |
destructive |
Boolean | Whether irreversible data or asset loss is possible |
idempotent |
Boolean | Whether repeated equivalent execution naturally has the same effect |
open_world |
Boolean | Whether the tool reads or affects systems outside Provider control |
confirmation |
never, provider_policy, always |
Per-call Provider-side confirmation policy |
execution |
sync, sync_or_async |
Whether the tool may return a background operation |
Normative constraints:
- A tool with
effect=readMUST NOT mutate business state beyond logging, caching, and metering. effect=delete,effect=transaction,destructive=true, orrisk=highrequiresconfirmation=always.execution=sync_or_asyncis valid only whenasync_operationswas negotiated.- Annotations inform Consumer UI and policy; they are not a security boundary. The Provider MUST validate them again at execution time.
- When runtime risk exceeds the static declaration, the Provider MUST apply the stricter policy and MAY require user action.
scopes is the complete set required to execute the tool. If any scope is missing, the Provider MUST
return AUTH_REQUIRED or PERMISSION_DENIED; it MUST NOT return degraded data as if it were complete.
4. Invocation Model¶
v2 defines these ContentResolver.call() methods:
| Method | Capability | Purpose |
|---|---|---|
negotiate |
Core | Select version, capabilities, and limits |
get_provider_info |
Core | Read descriptions, scope help, and support information |
list_tools |
Core | List tools visible to the current caller |
execute_tool |
Core | Start or resume a tool execution |
get_operation |
async_operations |
Query background execution state |
cancel_operation |
cancellation |
Request cancellation of background execution |
An unknown method MUST return METHOD_NOT_FOUND. The Provider distinguishes same-named v1 and v2
methods through request protocol_version; a dual-stack call without that field is handled as v1.
4.1 Common Request Fields¶
Except for negotiate, every v2 method MUST carry:
| Key | Bundle type | Requirement |
|---|---|---|
protocol_version |
Int |
Fixed at 2 |
session_token |
String |
Current negotiated session |
request_id |
String |
Required UUID, stable across one logical execution and its retries |
deadline_at_ms |
Long |
Required Unix epoch milliseconds |
execute_tool additionally carries tool_name, JSON string args, and optional resume_token. The
UTF-8 encoded arguments MUST NOT exceed negotiated max_request_bytes.
- For
execute_tool,request_ididentifies the logical execution and is its idempotency key. - For
get_operationandcancel_operation, extras MUST includeoperation_id, andrequest_idMUST equal the execution ID that created that operation. - For other methods,
request_ididentifies one retryable read. Retries retain it and a new read uses a new ID.
The Provider MUST check the deadline before starting a business side effect. A Consumer timeout does not
mean the Provider stopped. For side-effecting or asynchronous calls, the Consumer MUST query or retry with
the same request_id and MUST NOT create a new ID to guess the outcome.
4.2 Synchronous Success¶
Response Bundle:
status = "success"
protocol_version = 2
request_id = <original request ID>
data = {"kind":"inline","value":<any JSON matching output_schema>}
The Provider MUST validate its output against output_schema. The Consumer must still treat Provider
descriptions, errors, and output as untrusted data and MUST NOT automatically promote text within them to
Agent instructions.
4.3 Provider Authorization and Per-Call Confirmation¶
When login, scope authorization, or user confirmation is needed, the Provider returns AUTH_REQUIRED or
USER_ACTION_REQUIRED. In addition to common error fields, the Bundle MUST contain:
| Key | Bundle type | Meaning |
|---|---|---|
resolution |
PendingIntent |
Explicit, immutable PendingIntent created by the Provider |
resume_token |
String |
Opaque token bound to caller, tool, original arguments, request ID, and expiry |
The flow is:
Consumer Provider Provider UI
| execute_tool | |
|-------------------->| |
| USER_ACTION_REQUIRED + PendingIntent |
|<--------------------| |
| launch resolution -------------------------->|
| user approves/denies |
| execute_tool(same request_id, resume_token) |
|-------------------->| |
| success / error | |
- The Provider MUST NOT produce the target business side effect before confirmation completes.
- High-risk confirmation MUST occur in Provider UI; a Consumer-provided "confirmed" Boolean is invalid.
- A Provider confirmation Activity SHOULD finish after approval or denial. The Consumer waits for its Activity result before resuming the original invocation.
resume_tokenMUST be single-use and bound to the originalargsbytes. Changed arguments MUST fail.- User denial returns
USER_CANCELLED. - v2 does not use v1's implicit
authorize_intentaction string.
4.4 Asynchronous Operations¶
After negotiating async_operations, execute_tool may return:
status = "accepted"
protocol_version = 2
request_id = <original request ID>
data = {"operation_id":"op_123","poll_after_ms":1000}
The Consumer calls get_operation, and the Provider returns queued, running, succeeded, failed,
or cancelled. A succeeded state carries the same result envelope as synchronous success; a failed state
carries a structured error.
When the status query itself succeeds it uses status="success"; example data:
{
"operation_id": "op_123",
"state": "running",
"progress": {"current": 40, "total": 100, "message": "Synchronizing"},
"poll_after_ms": 1000
}
progress is optional display data and cannot establish business success. Terminal succeeded includes
result, while failed includes an error object matching Section 5 fields. A successful
cancel_operation only acknowledges the cancellation request and returns
state="cancellation_requested"; the Consumer must still query a terminal state.
operation_idMUST be bound to the calling UID and unavailable to other callers.- An operation MUST remain available until at least the end of the negotiated idempotency window. A Provider may advertise a longer TTL.
- Mutation deduplication records and operation state MUST be durable; Provider process restart MUST NOT cause duplicate execution.
- If
cancellationwas negotiated, the Consumer may callcancel_operation. - Cancellation is cooperative. Only a queried
cancelledstate means no later effect is expected. An already committed external transaction may returnCONFLICTand MUST NOT claim cancellation succeeded.
4.5 Large Results¶
When inline JSON exceeds max_inline_result_bytes, the Provider MUST NOT continue placing it in the
Bundle. After negotiating large_results, the success Bundle uses:
data = {
"kind":"file",
"media_type":"application/json",
"size_bytes":1048576,
"sha256":"<lowercase hex>"
}
result_fd = <read-only ParcelFileDescriptor>
The Consumer MUST enforce a local read limit, verify size, digest, and output_schema, and close the file
descriptor. The file belongs to one response; its path is not stable. Without large_results, the Provider
returns RESULT_TOO_LARGE.
5. Error Model¶
An error response MUST include status="error", error_code, a safely displayable error_message, a
Boolean retryable, and echoed protocol_version and request_id. Optional fields are JSON
error_details and Long retry_after_ms.
| Error code | Retryable | Meaning |
|---|---|---|
UNSUPPORTED_VERSION |
No | No common protocol version |
UNSUPPORTED_CAPABILITY |
No | Request uses an unnegotiated capability |
NEGOTIATION_REQUIRED |
Yes | Session is absent, expired, or lost after Provider restart |
AUTH_REQUIRED |
Conditional | User must log in or grant scopes |
USER_ACTION_REQUIRED |
Conditional | This call needs Provider-side confirmation |
USER_CANCELLED |
No | User denied or cancelled confirmation |
PERMISSION_DENIED |
No | Caller is not allowed access |
TOOL_NOT_FOUND |
No | Tool does not exist or is hidden from this caller |
METHOD_NOT_FOUND |
No | Method does not exist |
INVALID_ARGS |
No | Arguments do not match the input schema |
INVALID_REQUEST |
No | Envelope, token, or request ID use is invalid |
CONFLICT |
Conditional | Current state prevents execution or cancellation |
RATE_LIMITED |
Yes | Provider rate limit exceeded |
DEADLINE_EXCEEDED |
Conditional | Deadline elapsed; query the same request ID first |
CANCELLED |
No | Operation is confirmed cancelled |
RESULT_TOO_LARGE |
No | Large result transport was not negotiated and inline is impossible |
RESULT_EXPIRED |
No | Operation result exceeded retention |
UNAVAILABLE |
Yes | Provider is temporarily unavailable |
INTERNAL_ERROR |
Conditional | Internal failure without stacks or sensitive details |
A Consumer may automatically retry only when retryable=true and MUST honor retry_after_ms. It MUST
NOT loop on authorization, confirmation, or argument errors without user action or changed input.
6. Idempotency, Retry, and Concurrency¶
- Every tool with
effect != readMUST deduplicate byrequest_id, regardless of itsidempotentannotation. - The deduplication key includes at least calling UID,
provider_id, andrequest_id. Its record MUST bind tool name and a digest of the original arguments. Reusing the ID with different content returnsINVALID_REQUEST. - The Provider MUST return the same terminal outcome during
idempotency_window_seconds, whose minimum value is 86400 seconds. - Concurrent arrival of the same request permits only one executor; other calls return the same operation or terminal outcome.
- After Binder disconnect or timeout, a Consumer may only retry with the same ID or query status.
idempotent=truedescribes natural business semantics and does not remove request deduplication duties.
7. Security Model¶
7.1 Caller Identity¶
The Provider MUST synchronously capture Binder.getCallingUid() at the ContentProvider.call() entry
before switching execution contexts. Package names, caller fields in args, and self-reported Consumer
identity are untrusted.
For authorization, a Provider SHOULD:
- Resolve every package for the UID instead of taking the first package.
- Verify current signing-certificate digests and, where appropriate, Android signing-certificate rotation history.
- Bind grants to UID, package, certificate digest, scopes, and expiry.
- Deny or require reauthorization for shared UIDs, ambiguous package mappings, and signature changes.
com.clawseed.permission.ACCESS_TOOLS remains a discovery marker and defense in depth. Its normal
protection level is not an identity or authorization boundary.
7.2 Least Privilege and Confirmation¶
list_toolsSHOULD return only tools discoverable by the caller; sensitive tools may be hidden entirely.- The Provider MUST check scopes for every execution and cannot rely on a previous
list_toolsresult. - A
PendingIntentMUST explicitly target a Provider component and use the immutable flag. - High-risk UI MUST show the concrete target, action, critical parameters, and irreversible consequences, not only the tool description.
- A Provider SHOULD record redacted audit events containing caller, tool, request ID, authorization or confirmation outcome, and terminal state.
7.3 Untrusted Content¶
Provider names, tool descriptions, schemas, errors, and results may be malicious. A Consumer MUST enforce length limits, validate JSON/schema, and mark tool output as data. A Provider must likewise treat Agent-generated arguments as untrusted input and perform business validation beyond JSON Schema.
8. v1 Coexistence and Migration¶
| Phase | Provider | Consumer |
|---|---|---|
| 0: Current | Continue serving v1 read-only tools | Preserve existing v1 behavior |
| 1: Dual discovery | Add versions="1,2"; keep only read tools in v1 list |
Parse versions and implement negotiate |
| 2: v2 reads | Add schemas, scopes, annotations, and structured errors | A Provider advertising v2 must negotiate successfully or be disabled |
| 3: Controlled writes | Expose mutations only through v2 with confirmation and deduplication | Present risk and correctly resume confirmation |
| 4: Async/large | Enable optional capabilities as needed | Use methods only after negotiation |
Fallback rules:
- A Consumer uses v1 only when the Provider manifest includes v1 and does not advertise v2. Failed v2 negotiation MUST NOT downgrade.
- Failed v2 negotiation, authorization, or security validation MUST NOT downgrade to v1 to bypass controls.
- A v1 Consumer can see only the dual-stack Provider's read-only subset.
- Existing side-effecting v1 extensions SHOULD migrate to v2. Until then, Consumers SHOULD disable them by default or require local human approval and MUST NOT describe them as v1-conformant.
9. Conformance Requirements¶
Before v2 is released, it SHOULD have shared test vectors and Android Provider/Consumer suites covering:
- manifest version sets, no common version, and dual-stack fallback;
- unknown fields, missing required fields, invalid JSON, and schema mismatch;
- caller UID/signature changes, scope expiry, PendingIntent binding, and token replay;
- concurrent same-ID calls, retry after timeout, different arguments with one ID, and idempotency windows;
- approval, denial, token expiry, and absence of effects before confirmation;
- operation success, failure, cancellation races, and Provider restart recovery;
- Binder inline boundaries, invalid file digests, bounded reads, and descriptor closure; and
- malicious Provider descriptions, injected instructions, unknown errors, and sensitive error content.
An implementation may claim CETP v2 only after all core tests pass. Each optional capability must be declared and pass its corresponding tests independently.
10. Android SDK Implementation¶
The SDK provides CetpClient, ExternalToolBridge, and an extensible CetpV2ContentProvider. A minimal
Provider has this shape:
class AlertToolProvider : CetpV2ContentProvider() {
override val provider = CetpProviderDescriptor(
providerId = "com.example.app",
providerName = "Example App",
description = "Price alerts",
scopes = listOf(
ProviderScope(
name = "alerts.write",
description = "Manage price alerts",
access = ScopeAccess.WRITE,
sensitivity = ScopeSensitivity.FINANCIAL,
),
),
)
override val providerTools = listOf(
CetpProviderTool(
name = "create_alert",
title = "Create price alert",
description = "Create a price alert for a security",
inputSchema = createAlertInputSchema,
outputSchema = createAlertOutputSchema,
scopes = listOf("alerts.write"),
annotations = ToolAnnotations(
effect = ToolEffect.CREATE,
risk = ToolRisk.MODERATE,
idempotent = false,
confirmation = ConfirmationPolicy.PROVIDER_POLICY,
),
),
)
override val mutationExecutor = appDurableMutationExecutor
override fun authorize(request: CetpProviderRequest): CetpAccessDecision =
appAuthorizationPolicy.authorize(request)
override fun executeTool(request: CetpProviderRequest): CetpProviderResult =
alertRepository.execute(request)
}
CetpV2ContentProvider denies authorization by default and rejects inconsistent high-risk declarations,
expired deadlines, invalid sessions, and mutation tools without a CetpMutationExecutor. The application's
mutation executor must persist request reservation, business mutation, and result within one reliable
boundary; the SDK cannot substitute for atomicity in the business database.
The SDK owns protocol envelopes, negotiation tokens, caller UID/signature collection, structured errors, async polling/cancellation, file-result verification, and Provider UI resumption. The Provider application still owns JSON Schema and business validation, scope policy, confirmation-token state, operation persistence, and audit records.