Fibric. Docs fibric.io →
v1.0.0 · stable
Build

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.

@fibric/connector-sdk
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;
FieldTypeRequiredDescription
input(args: unknown) => Record<string, unknown>noValidator for the tool's arguments. Receives the raw args, returns the validated shape, throws on anything malformed. Runs before the handler, always.
sideEffectingbooleannoDefaults 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> | unknownyesThe 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.

Zod as the validator
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.

+
Validate values, not only shapes

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:

@fibric/kernel — PlannedAction
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:

  1. Wait on any in-flight action holding the same entity_key (single-flight per entity).
  2. If the idempotency_key has been seen, return a DEDUP disposition without calling your handler.
  3. Evaluate the trust policy, default-closed: no matching rule means BLOCK.
  4. Call your handler. On success, record the idempotency key; on failure, do not, so a retry is possible.
  5. 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:

LayerResponsibility
OperatorConstructs idempotency_key and entity_key for every proposed action.
ExecutorDedupes 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.
ConnectorWhere 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.
!
Handlers still run at-least-once at the edges

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.

@fibric/connector-sdk — AuthSchema
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;
KindHelperWhen to use it
api_keyapiKey()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.
oauth2oauth2({ 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.
basicliteral { kind: 'basic' }Username and password over TLS, common on older on-prem systems and building-management gateways.
aws_iamliteral { kind: 'aws_iam' }AWS-native targets. Authentication is role assumption, so no long-lived secret exists at all.
mtlsliteral { kind: 'mtls' }Mutual TLS with a client certificate, typical for industrial and hardware endpoints.
nonenone()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.

Declaring scoped OAuth
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:

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:

CLI
# 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