Tools & auth
A tool is one thing a connector can do; auth is how the connector proves it may do it. Both are declared, not coded: the tool() helper describes a capability's input, its handler, and whether it changes the world, and the auth helpers describe how credentials are obtained without ever holding one. This page covers the ToolDef contract, input validation, how idempotency is wired for side-effecting tools, every supported auth kind, and the secret-handling rules the platform enforces.
The tool() helper
tool() takes a ToolDef and returns it unchanged, giving you type checking and a stable shape the registry can read. Tools live in the tools record of a ConnectorDef, keyed by the capability name operators will ask for.
export interface ToolDef {
// minimal validator seam (Zod in production); returns the validated args
input?: (args: unknown) => Record<string, unknown>;
sideEffecting?: boolean; // true: routes through the deterministic executor + TrustPolicy
handler: (ctx: ConnectorCtx, args: Record<string, unknown>) => Promise<unknown> | unknown;
}
export function tool(def: ToolDef): ToolDef;
| Field | Type | Required | Description |
|---|---|---|---|
input | (args: unknown) => Record<string, unknown> | no | Validator for the tool's arguments. Receives the raw args, returns the validated shape, throws on anything malformed. Runs before the handler, always. |
sideEffecting | boolean | no | Defaults to false. When true, the tool is reachable only through the deterministic executor: trust policy, single-flight, idempotency dedup, receipt. When false, the tool runs inline as a read. |
handler | (ctx, args) => Promise<unknown> | unknown | yes | The actual call to the target system. Receives the tenant-scoped ConnectorCtx and the validated args. Should do one thing and throw honestly when it fails. |
Input validation
The input function is a deliberate seam rather than a bundled schema library. The SDK's contract is minimal: raw args in, validated record out, exception on anything else. In production connectors the conventional implementation is a Zod schema's parse, which satisfies the contract exactly.
import { z } from 'zod';
const RefundArgs = z.object({
order_id: z.string().min(1),
amount: z.number().positive(),
reason: z.enum(['damaged', 'late', 'goodwill']),
});
'order.refund': tool({
sideEffecting: true,
input: (args) => RefundArgs.parse(args), // throws ZodError on bad input
handler: async (ctx, args) => {
// args is already validated: { order_id, amount, reason }
ctx.log('refunding', { order: args.order_id, amount: args.amount });
// return await ctx.http.post(`/orders/${args.order_id}/refund`, args);
},
}),
Validation failures are cheap and early. For a read, the caller gets the error directly. For a side-effecting tool, the plan's action fails validation before the executor ever consults the trust policy, so a malformed proposal never consumes a policy decision, never takes a lock, and never burns an idempotency key. Validate strictly; the worst a strict validator can do is refuse an argument that should never have been proposed.
An operator proposes arguments a model produced. Bound them. If a refund should never exceed the order total, check it here, and also cap it in policy with maxValue. Two independent layers, one in the connector and one in the trust policy, is the intended design, and they fail independently.
sideEffecting and the executor
The sideEffecting flag is the most consequential boolean in the SDK. It splits every tool into one of two execution paths, and the split is enforced by the kernel, not by convention.
Reads (sideEffecting absent or false) run inline. No policy is consulted and no idempotency applies, because reading twice is harmless. This is how operators sense state during a run.
Writes (sideEffecting: true) can only be reached through a validated ExecutionPlan. Each action in a plan carries the fields the executor needs, and the kernel type makes them mandatory:
export interface PlannedAction {
connector: string;
tool: string;
args: Record<string, unknown>;
value?: number; // e.g. a refund amount, for max_value policies
entity_key: string; // single-flight key: serialize side-effects per entity
idempotency_key: string; // dedup key for the side-effect
}
When the executor disposes an action against a side-effecting tool, it runs this sequence, in order, every time:
- Wait on any in-flight action holding the same
entity_key(single-flight per entity). - If the
idempotency_keyhas been seen, return aDEDUPdisposition without calling your handler. - Evaluate the trust policy, default-closed: no matching rule means
BLOCK. - Call your handler. On success, record the idempotency key; on failure, do not, so a retry is possible.
- Write the receipt either way.
Idempotency key wiring
You do not generate idempotency keys inside a tool. The proposing operator builds them into the plan, conventionally operator:entity:action, and the executor dedupes on them before your handler runs. The consequence for connector authors is a simple division of labor:
| Layer | Responsibility |
|---|---|
| Operator | Constructs idempotency_key and entity_key for every proposed action. |
| Executor | Dedupes on the key, serializes on the entity, records success. In the MVP the seen-set is in memory; in production it is durable (Postgres/DynamoDB), so dedup survives restarts. |
| Connector | Where the target API supports it, forward the key as the vendor's idempotency header so the dedup extends into their system too. Stripe-style APIs accept it directly. |
Dedup is recorded after a successful handler call. If your handler succeeds against the vendor but crashes before returning, a retry will call it again. Where duplicate writes are costly, pass the plan's idempotency key through to the vendor API so the second call collapses on their side as well.
Auth declarations
Auth in the SDK is a declaration of kind, never of material. The def says "this connector authenticates with an API key" or "with OAuth 2.0 and these scopes"; the platform handles acquisition, storage, and injection per tenant. No field in the SDK accepts a credential, by design.
export type AuthKind = 'oauth2' | 'api_key' | 'basic' | 'aws_iam' | 'mtls' | 'none';
export interface AuthSchema {
kind: AuthKind;
scopes?: string[];
}
// helpers
export function oauth2(opts: { scopes?: string[] } = {}): AuthSchema;
export function apiKey(): AuthSchema;
export function none(): AuthSchema;
| Kind | Helper | When to use it |
|---|---|---|
api_key | apiKey() | The common SaaS case: one long-lived key per account. The key is entered once at connection time and lands directly in the tenant's secret store. |
oauth2 | oauth2({ scopes }) | Vendors with an OAuth 2.0 authorization flow. Declare the scopes you need and no more; the platform runs the flow and manages token refresh. |
basic | literal { kind: 'basic' } | Username and password over TLS, common on older on-prem systems and building-management gateways. |
aws_iam | literal { kind: 'aws_iam' } | AWS-native targets. Authentication is role assumption, so no long-lived secret exists at all. |
mtls | literal { kind: 'mtls' } | Mutual TLS with a client certificate, typical for industrial and hardware endpoints. |
none | none() | Genuinely unauthenticated sources, such as a public status feed, and operators, which hold no credentials of their own and act through the connectors the tenant has already authorized. |
The SDK ships helpers for the three most common kinds. The other kinds are declared as a literal AuthSchema, for example auth: { kind: 'mtls' }. Scopes are only meaningful for oauth2.
import { defineConnector, oauth2 } from '@fibric/connector-sdk';
export default defineConnector({
id: 'cn-brightdesk',
version: '1.1.0',
category: 'comms',
auth: oauth2({ scopes: ['conversations.read', 'notes.write'] }),
tools: { /* ... */ },
});
Secret handling rules
Credential material never appears in connector code, and the runtime is built so that it does not have to. Per-tenant credentials live in the tenant's secret store (AWS Secrets Manager in the managed platform) and are resolved at call time into ctx.http, the pre-authenticated client the runtime mounts on the context. These rules are enforced at review for marketplace connectors and are the right defaults for private ones:
- Never accept a credential through
config. Theconfigrecord is for non-secret settings: subdomains, regions, queue names. It is visible in the workspace UI; a secret placed there is exposed. - Never log credential material.
ctx.logoutput is correlated into traces and receipts that people read. Log identifiers, not tokens. - Never persist a credential in connector state. If a token needs refreshing, that is the platform's job under
oauth2, not the handler's. - Request the minimum. Declare only the OAuth scopes your tools need. Review compares your declared scopes against your tool surface.
Sandbox vs live credentials
A connection holds one account's credentials, and a tenant can hold more than one connection per connector. The working pattern during development is a sandbox connection pointed at the vendor's test environment, alongside the live one:
# one connector, two connections
fibric connectors add cn-brightdesk --connection brightdesk-sandbox
fibric connectors add cn-brightdesk --connection brightdesk-live
# exercise a tool against the sandbox connection
fibric connectors test cn-brightdesk conversation.read \
--connection brightdesk-sandbox \
--args '{"conversation_id":"cnv_3021"}'
Two properties keep this safe. Sandbox and live credentials are separate secrets in separate connections, so a test can never silently pick up the live key. And side-effecting tools invoked through fibric connectors test dry-run by default: the validator and the trust evaluation run, the handler does not. The testing page covers the full local harness, including replaying recorded envelopes and asserting on proposed plans.
Keep going
- defineConnector(): the full
ConnectorDefreference these tools live inside. - Single-flight & idempotency: the kernel primitives behind
entity_keyandidempotency_key. - Testing connectors: the local harness, fixtures, and trust-tier simulation.
- Tenancy & isolation: why
ctxcan only ever see one tenant.