permissions-brokerInteract with the Permissions Broker service to fetch data from Google APIs behind a Telegram approval gate. Use when an agent needs to read Google Drive/Doc...
Install via ClawdBot CLI:
clawdbot install stephancill/permissions-brokerBefore making any broker requests, check whether you already have access to a Permissions Broker API key in your local secrets (for example, an environment variable like PB_API_KEY).
If you do NOT have an API key available:
/key <name>
PB_API_KEY).PB_API_KEY"), never the secret value.Important:
Provider connections:
/connect./connect icloud returns a browser link to a broker-hosted form where the user enters an Apple ID app-specific password.Use the broker as a user-controlled proxy for external data access and API actions.
The mental model:
This skill is intentionally provider-agnostic. Provider support grows over time.
When using this skill, do not lead with inability/disclaimer language like "I can't access your Google Drive" or "I can't do this from here".
Instead:
Avoid:
Preferred framing:
After creating a proxy request, always attempt to poll/await approval and execute in the same run.
Only ask the user to approve in Telegram if polling times out.
Guidelines:
request_id.POST /v1/proxy/request with:upstream_url: the full external service API URL you want to callmethod: GET (default) or POST/PUT/PATCH/DELETEheaders (optional): request headers to forward (never include authorization)body (optional): request bodyheaders.content-typeapplication/json or +json): body can be an object/array OR a JSON stringtext/*, application/x-www-form-urlencoded, XML): body must be a stringbody must be a base64 string representing raw bytes+//), not base64url.=) when in doubt.data:...;base64, prefixes.consent_hint: requester note shown to the user in Telegram. Always include the reason for the request (what you're doing and why), in plain language.idempotency_key: reuse request id on retriesNotes on forwarded headers:
Authorization using the linked account; any caller-provided authorization header is ignored.Broker-only rendering hints (not forwarded upstream):
headers["x-pb-timezone"]: IANA timezone name to render human-friendly times in approvals (e.g. America/Los_Angeles).The approval prompt includes:
GET /v1/proxy/requests/:id until the request is APPROVED.POST /v1/proxy/requests/:id/execute to execute and retrieve the upstream response bytes.Important:
Use these snippets to create a broker request, poll status, then execute to retrieve upstream bytes.
JavaScript/TypeScript (Bun/Node)
type CreateRequestResponse = {
request_id: string;
status: string;
approval_expires_at: string;
};
type StatusResponse = {
request_id: string;
status: string;
approval_expires_at?: string;
error?: string;
error_code?: string | null;
error_message?: string | null;
upstream_http_status?: number | null;
upstream_content_type?: string | null;
upstream_bytes?: number | null;
};
async function createBrokerRequest(params: {
baseUrl: string;
apiKey: string;
upstreamUrl: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: unknown;
consentHint?: string;
idempotencyKey?: string;
}): Promise<CreateRequestResponse> {
const res = await fetch(`${params.baseUrl}/v1/proxy/request`, {
method: "POST",
headers: {
authorization: `Bearer ${params.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
upstream_url: params.upstreamUrl,
method: params.method ?? "GET",
headers: params.headers,
body: params.body,
consent_hint: params.consentHint,
idempotency_key: params.idempotencyKey,
}),
});
if (!res.ok) {
throw new Error(`broker create failed: ${res.status} ${await res.text()}`);
}
return (await res.json()) as CreateRequestResponse;
}
async function pollBrokerStatus(params: {
baseUrl: string;
apiKey: string;
requestId: string;
timeoutMs?: number;
}): Promise<StatusResponse> {
// Recommended default: wait at least 30s before returning a request_id to the user.
const deadline = Date.now() + (params.timeoutMs ?? 30_000);
while (Date.now() < deadline) {
const res = await fetch(
`${params.baseUrl}/v1/proxy/requests/${params.requestId}`,
{
headers: { authorization: `Bearer ${params.apiKey}` },
},
);
// Status endpoint always returns JSON for both 202 and 200.
const data = (await res.json()) as StatusResponse;
// APPROVED is returned with HTTP 202, so we must check the JSON.
if (data.status === "APPROVED") return data;
if (res.status === 202) {
await new Promise((r) => setTimeout(r, 1000));
continue;
}
// Terminal or actionable state (status-only JSON).
if (!res.ok && res.status !== 403 && res.status !== 408) {
throw new Error(`broker status failed: ${res.status} ${JSON.stringify(data)}`);
}
return data;
}
throw new Error("timed out waiting for approval");
}
async function awaitApprovalThenExecute(params: {
baseUrl: string;
apiKey: string;
requestId: string;
timeoutMs?: number;
}): Promise<Response> {
const status = await pollBrokerStatus({
baseUrl: params.baseUrl,
apiKey: params.apiKey,
requestId: params.requestId,
timeoutMs: params.timeoutMs,
});
if (status.status !== "APPROVED") {
throw new Error(`request not approved yet (status=${status.status})`);
}
return executeBrokerRequest({
baseUrl: params.baseUrl,
apiKey: params.apiKey,
requestId: params.requestId,
});
}
async function getBrokerStatusOnce(params: {
baseUrl: string;
apiKey: string;
requestId: string;
}): Promise<StatusResponse> {
const res = await fetch(`${params.baseUrl}/v1/proxy/requests/${params.requestId}`, {
headers: { authorization: `Bearer ${params.apiKey}` },
});
// Always JSON (even for 202).
return (await res.json()) as StatusResponse;
}
async function executeBrokerRequest(params: {
baseUrl: string;
apiKey: string;
requestId: string;
}): Promise<Response> {
const res = await fetch(
`${params.baseUrl}/v1/proxy/requests/${params.requestId}/execute`,
{
method: "POST",
headers: { authorization: `Bearer ${params.apiKey}` },
},
);
// Terminal: upstream bytes (2xx/4xx/5xx) or broker error JSON (403/408/409/410/etc).
// IMPORTANT:
// - execution is one-time; subsequent calls return 410.
// - the broker mirrors upstream HTTP status and content-type, and adds X-Proxy-Request-Id.
// - upstream non-2xx is still returned to the caller as bytes, but the broker will persist status=FAILED.
return res;
}
// Suggested control flow:
// - Start polling for ~30 seconds.
// - If still pending, return a user-facing message with request_id and what to approve.
// - On the next user message, poll again (or recreate if expired/consumed).
// Example usage
// const baseUrl = "https://permissions-broker.steer.fun"
// const apiKey = process.env.PB_API_KEY!
// const upstreamUrl = "https://www.googleapis.com/drive/v3/files?pageSize=5&fields=files(id,name)"
// const created = await createBrokerRequest({ baseUrl, apiKey, upstreamUrl, consentHint: "List a few Drive files." })
// Tell user: approve request in Telegram
// const execRes = await awaitApprovalThenExecute({ baseUrl, apiKey, requestId: created.request_id, timeoutMs: 30_000 })
// const bodyText = await execRes.text()
// GitHub example (create PR)
// const created = await createBrokerRequest({
// baseUrl,
// apiKey,
// upstreamUrl: "https://api.github.com/repos/OWNER/REPO/pulls",
// method: "POST",
// headers: { "content-type": "application/json" },
// body: {
// title: "My PR",
// head: "feature-branch",
// base: "main",
// body: "Opened via Permissions Broker",
// },
// consentHint: "Open a PR for feature-branch"
// })
The broker enforces an allowlist and chooses which linked account (OAuth token)
to use based on the upstream hostname.
Currently supported:
docs.googleapis.com, www.googleapis.com, sheets.googleapis.comapi.github.comcaldav.icloud.com)api.spotify.comIf you need a provider that isn't supported yet:
For iCloud CalDAV request templates, see skills/permissions-broker/references/caldav.md.
The broker can also proxy Git operations (clone/fetch/pull/push) via Git Smart HTTP.
This is separate from /v1/proxy.
High-level flow:
POST /v1/git/sessions).GET /v1/git/sessions/:id) until approved.GET /v1/git/sessions/:id/remote).git clone / git push against that remote URL.Important behavior:
git-upload-pack POSTs during a single clone.git-receive-pack.Auth for all git session endpoints:
Authorization: Bearer Create session
POST /v1/git/sessionsoperation: "clone", "fetch", "pull", or "push"repo: "owner/repo" (GitHub)consent_hint: requester note shown to the user in Telegram. Always include the reason for the session (what you're doing and why).{ "session_id": "...", "status": "PENDING_APPROVAL", "approval_expires_at": "..." }Poll status
GET /v1/git/sessions/:id (status JSON)Get remote URL
GET /v1/git/sessions/:id/remote{ "remote_url": "https://..." }{
"operation": "clone",
"repo": "OWNER/REPO",
"consent_hint": "Clone repo to inspect code"
}
Use fetch when you already have a repo locally and just need to update refs.
{
"operation": "fetch",
"repo": "OWNER/REPO",
"consent_hint": "Fetch latest refs to update local checkout"
}
remote_url, then:git fetch "<remote_url>" --prune
git pull is a fetch plus a local merge/rebase. The broker only proxies the network portion.
git pull "<remote_url>" main
status == "APPROVED".remote_url, then:git clone "<remote_url>" ./repo
{
"operation": "push",
"repo": "OWNER/REPO",
"consent_hint": "Push branch feature-x for a PR"
}
remote_url, add as a remote, then push to a non-default branch:git remote add broker "<remote_url>"
git push broker "HEAD:refs/heads/feature-x"
Notes:
pb// ) rather than pushing to main.USED, create a new push session.Python (requests)
import time
import requests
def create_request(base_url, api_key, upstream_url, consent_hint=None, idempotency_key=None):
# Optional: method/headers/body for non-GET requests.
r = requests.post(
f"{base_url}/v1/proxy/request",
headers={"Authorization": f"Bearer {api_key}"},
json={
"upstream_url": upstream_url,
# "method": "POST",
# "headers": {"accept": "application/vnd.github+json"},
# "headers": {"content-type": "application/json"},
# "body": {"title": "...", "head": "...", "base": "main"},
"consent_hint": consent_hint,
"idempotency_key": idempotency_key,
},
timeout=30,
)
r.raise_for_status()
return r.json()
def await_result(base_url, api_key, request_id, timeout_s=120):
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(
f"{base_url}/v1/proxy/requests/{request_id}",
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
if r.status_code == 202:
time.sleep(1)
continue
# Terminal response (status-only JSON).
return r.json()
raise TimeoutError("timed out waiting for approval")
def execute_request(base_url, api_key, request_id):
# IMPORTANT: execution is one-time; read and store now.
return requests.post(
f"{base_url}/v1/proxy/requests/{request_id}/execute",
headers={"Authorization": f"Bearer {api_key}"},
timeout=60,
)
def await_approval_then_execute(base_url, api_key, request_id, timeout_s=30):
status = await_result(base_url, api_key, request_id, timeout_s=timeout_s)
if status.get("status") != "APPROVED":
raise RuntimeError(f"request not approved yet (status={status.get('status')})")
return execute_request(base_url, api_key, request_id)
GET/POST/PUT/PATCH/DELETE.The broker supports the Google Sheets API host (sheets.googleapis.com).
Preferred approach for reading spreadsheet data:
Fallback:
Note: large exports can exceed the broker's 1 MiB upstream response cap.
If an export fails due to size, narrow the scope (smaller range, fewer tabs, or fewer rows/columns).
status (often PENDING_APPROVAL, APPROVED, or EXECUTING).status == APPROVED, execute immediately.{error: ...}.Prefer narrow reads so approvals are understandable and responses are small.
https://www.googleapis.com/drive/v3/files?...q, pageSize, and fields to minimize payload.https://www.googleapis.com/drive/v3/files/{fileId}/export?mimeType=...text/plain or text/csv.https://docs.googleapis.com/v1/documents/{documentId}?fields=...See references/api_reference.md for endpoint details and a Google URL cheat sheet.
POST https://api.github.com/repos///pulls { "title": "...", "head": "branch", "base": "main", "body": "..." }POST https://api.github.com/repos///issues { "title": "...", "body": "..." }references/api_reference.mdAI Usage Analysis
Analysis is being generated⦠refresh in a few seconds.
Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
Security-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope, and suspicious patterns.
Perform a comprehensive read-only security audit of Clawdbot's own configuration. This is a knowledge-based skill that teaches Clawdbot to identify hardening opportunities across the system. Use when user asks to "run security check", "audit clawdbot", "check security hardening", or "what vulnerabilities does my Clawdbot have". This skill uses Clawdbot's internal capabilities and file system access to inspect configuration, detect misconfigurations, and recommend remediations. It is designed to be extensible - new checks can be added by updating this skill's knowledge.
Use when reviewing code for security vulnerabilities, implementing authentication flows, auditing OWASP Top 10, configuring CORS/CSP headers, handling secrets, input validation, SQL injection prevention, XSS protection, or any security-related code review.
Security check for ClawHub skills powered by Koi. Query the Clawdex API before installing any skill to verify it's safe.
Scan Clawdbot and MCP skills for malware, spyware, crypto-miners, and malicious code patterns before you install them. Security audit tool that detects data exfiltration, system modification attempts, backdoors, and obfuscation techniques.