For assistants
Writing a task for Faber.
Everything needed to write a task's code and hand it to Faber withsubmit_task_source. Faber checks the code, sets the task up, then reads it back and writes the task's instructions from what the code actually does, so the person approves a description that matches behaviour they cannot read.
What belongs here
Faber is for work that writes to a third-party account, needs a condition checked before a write, or has to recur after this conversation ends. A reminder with nothing to read and nothing to write belongs in your own scheduler. A one-off draft or summary belongs in the chat.
Check browse_task_catalog first. If the catalog already has this task, install_task gives the person the tested version, and writing a copy loses that.
The three files
A submission carries these and nothing else.
main.ts- The program. Required. It exports a default async function and imports from
faber-connectors. manifest.json- Only
name,settings,inputsandtimeout. Faber works outconnectors,allow_net,force_live_stepsandrequested_approval_stepsfrom the code, and drops them if you send them: a shipped copy is stale the moment the code changes. In particularallow_netgrants nothing on its own. Network access comes from importing thehttpconnector, and the runtime never readsmain.tsto widen permissions. approval_rules.json- One entry per write that should wait for the person, keyed by that write's label.
main.tscontains nothing about approval. A rule can only ADD a hold: it can never release one the person set.
spec.md is refused. Faber writes it from your code, which is the point of submitting rather than describing: the person is approving a task whose code they cannot read, and a description written by the same author as the code tells them nothing they did not already have to take on trust.
setup.ts is refused too, for now. Inline scripts still run in the same process as the connectors, so a derivation script from an outside author is not isolated yet.
Writes and approval
Every write takes a label. It is that write's identity: the person sees it, an approval rule is keyed to it, and renaming it strands the rule. Give each write a distinct one.
// approval_rules.json
[
{
"key": "send-the-invoice-reminder",
"label": "Send the invoice reminder",
"hold": "when",
"body": "return write.amount > 5000;",
"approval_rule": "the amount is over your limit"
}
]
// "hold" is one of:
// "never" registered, holds nothing by itself (the person can still turn it on)
// "always" waits every run, every item
// "when" waits when the write's own payload matches "body"Previews
A preview runs the program with every write captured and discarded, and it is what the person sees before they approve anything. Two things are worth knowing while you write:
- A program that writes nothing and calls no
result()is refused, because its preview would show the person nothing. http.fetchdecides read from write by the HTTP verb alone, and reads run live even in a preview. AGETwith side effects will reach the world during a preview. Use a write verb for anything that changes something.
Connectors
Import only from faber-connectors. This is the same reference Faber's own builder is given.
ai
```typescript
type AiEffort = "low" | "medium" | "high";
type AiQuality = "faster" | "balanced" | "smartest";
type AiTask = "other" | "analyze" | "classify" | "extract" | "summarize" | "generate" | "transform" | "reason";
type AiCallOptions = { label: string; quality?: AiQuality; effort?: AiEffort; task?: AiTask; profile?: string[] };
type Medium = "email" | "chat" | "document" | "none";
type Format = "text" | "html" | "markdown";
await ai.analyze({ prompt: string, data: any }, callOptions: AiCallOptions): Promise<AnalysisResult>
await ai.structured({ prompt: string, data?: any, schema?: any }, callOptions: AiCallOptions): Promise<StructuredResult>
await ai.generate({ prompt: string, data?: any, medium: Medium, format?: Format }, callOptions: AiCallOptions): Promise<GenerateResult>
interface AnalysisResult {
text: string; // Raw AI response as a string.
parsed: unknown | null; // Auto-parsed JSON if the response was valid JSON, otherwise null.
tokens: { input: number; output: number };
estimatedCost: number;
}
interface StructuredResult {
text: string; // Raw AI response as a string.
parsed: unknown; // Parsed JSON. ai.structured throws if the response was not valid JSON.
tokens: { input: number; output: number };
estimatedCost: number;
}
interface GenerateResult {
text: string;
tokens: { input: number; output: number };
estimatedCost: number;
}
```airtable
```typescript
interface AirtableBase {
id: string; // "app..."
name: string;
permissionLevel: string; // "none" | "read" | "comment" | "edit" | "create"
}
interface AirtableField { id: string; name: string; type: string }
interface AirtableTable {
id: string; // "tbl..."
name: string;
primaryFieldId: string;
fields: AirtableField[];
}
interface AirtableRecord {
id: string; // "rec..."
createdTime: string;
fields: Record<string, unknown>; // keyed by field NAME
}
interface AirtableRecordInput { id?: string; fields: Record<string, unknown> }
interface AirtableWriteResult {
ids: string[];
records: AirtableRecord[];
}
// Bases the connected account granted access to. For the base a task targets,
// prefer the `airtableBase({ label, description })` picker setting over listing here — see usage.
airtable.listBases(options?: { limit?: number, cursor?: string }): Promise<Page<AirtableBase>>
// Tables in a base, with their fields — how to discover field names and types.
airtable.listTables(options: { base: string }): Promise<Page<AirtableTable>>
// Records from one table. `table` accepts the table name or its id.
// `view` applies that view's own filter and sort. `filterByFormula` is a raw
// Airtable formula — prefer findRecords for simple equality.
airtable.listRecords(options: { base: string; table: string; view?: string; filterByFormula?: string; fields?: string[]; sort?: { field: string; direction?: "asc" | "desc" }[]; limit?: number, cursor?: string }): Promise<Page<AirtableRecord>>
// Records where one field equals a value. Escapes the formula for you — use
// this rather than hand-building filterByFormula whenever the match is equality.
airtable.findRecords(options: { base: string; table: string; field: string; value: string | number | boolean; limit?: number, cursor?: string }): Promise<Page<AirtableRecord>>
// One record by id.
airtable.getRecord(options: { base: string; table: string; recordId: string }): Promise<AirtableRecord>
interface AirtableComment {
id: string;
createdTime: string;
lastUpdatedTime: string | null; // null until edited
text: string; // may embed mentions as "@[usrXXXX]"
author: { id: string; email?: string; name?: string };
parentCommentId?: string; // set on a threaded reply
mentioned?: Record<string, { id: string; email?: string; name?: string }>;
}
interface AirtableCommentRef { // what a comment write returns — reply to it via parentCommentId
id: string;
}
// Comments on one record, NEWEST FIRST.
airtable.listComments(options: { base: string; table: string; recordId: string; limit?: number, cursor?: string }): Promise<Page<AirtableComment>>
// The connected Airtable account.
airtable.getCurrentUser(): Promise<{ id: string; email?: string; scopes?: string[] }>
// Create up to 10 records. WRITE — requires `label`. `typecast: true` lets
// Airtable coerce strings into typed cells (dates, numbers, select options).
airtable.createRecords(options: { label: string; base: string; table: string; records: AirtableRecordInput[]; typecast?: boolean }): Promise<AirtableWriteResult>
// Update up to 10 records by id. WRITE — requires `label`.
// mode "merge" (default) changes only the fields you pass.
// mode "overwrite" CLEARS every field you do not pass. See usage.
airtable.updateRecords(options: { label: string; base: string; table: string; records: AirtableRecordInput[]; mode?: "merge" | "overwrite"; typecast?: boolean }): Promise<AirtableWriteResult>
// Create-or-update up to 10 records, matched on `fieldsToMergeOn`. WRITE — requires `label`.
airtable.upsertRecords(options: { label: string; base: string; table: string; records: AirtableRecordInput[]; fieldsToMergeOn: string[]; typecast?: boolean }): Promise<AirtableWriteResult>
// Delete up to 10 records by id. WRITE — requires `label`. Not recoverable.
airtable.deleteRecords(options: { label: string; base: string; table: string; recordIds: string[] }): Promise<void>
// Comment on a record. WRITE — requires `label`. Pass `parentCommentId` to reply
// in a thread instead of starting one.
airtable.createComment(options: { label: string; base: string; table: string; recordId: string; text: string; parentCommentId?: string }): Promise<AirtableCommentRef>
// Edit a comment. WRITE — requires `label`. Only comments Faber's own connected
// account created; editing anyone else's fails regardless of base permissions.
airtable.updateComment(options: { label: string; base: string; table: string; recordId: string; commentId: string; text: string }): Promise<AirtableCommentRef>
// Delete a comment. WRITE — requires `label`. Same ownership rule as update.
airtable.deleteComment(options: { label: string; base: string; table: string; recordId: string; commentId: string }): Promise<void>
```archive
```typescript
// Archive - list and extract compressed files
type ArchiveFormat = "zip" | "tar" | "tar.gz" | "gz";
type ArchiveInput = Uint8Array | ArrayBuffer | ArrayBufferView;
type ArchiveEntryPattern = string | RegExp;
archive.list({ bytes: ArchiveInput, name?: string, format?: ArchiveFormat }): ArchiveEntryInfo[]
archive.extract({ bytes: ArchiveInput, name?: string, format?: ArchiveFormat, include?: ArchiveEntryPattern | ArchiveEntryPattern[], exclude?: ArchiveEntryPattern | ArchiveEntryPattern[], decodeText?: boolean, encoding?: string }): ArchiveEntry[]
interface ArchiveEntryInfo {
name: string;
size: number;
extension: string;
}
interface ArchiveEntry extends ArchiveEntryInfo {
bytes: Uint8Array;
text?: string;
}
```banking
```typescript
type BankingProvider = "mercury" | "brex";
type BankTransactionStatus =
| "pending"
| "sent"
| "cancelled"
| "failed"
| "reversed"
| "blocked";
interface BankAccount {
id: string;
name: string;
status?: string;
currentBalance: number;
availableBalance: number;
currency: string;
kind?: string;
primary?: boolean;
}
interface BankTransaction {
id: string;
accountId: string;
amount?: number;
currency: string;
description: string;
postedAt?: string;
createdAt?: string;
kind?: string;
link?: string;
}
interface BankStatement {
id: string;
accountId: string;
startDate: string;
endDate: string;
startingBalance?: number;
endingBalance?: number;
currency: string;
}
interface BankOrganization {
id: string;
legalName?: string;
}
interface ListTransactionsOptions {
accountId?: string;
postedStart?: string;
limit?: number;
cursor?: string;
status?: BankTransactionStatus[];
search?: string;
postedEnd?: string;
start?: string;
end?: string;
order?: "asc" | "desc";
}
interface ListStatementsOptions {
accountId: string;
limit?: number;
cursor?: string;
start?: string;
end?: string;
order?: "asc" | "desc";
}
interface BankAccountOptions {
label: string;
description: string;
}
interface PickedBankAccount {
id: string;
name: string;
}
// Every account in the connected bank, with balances.
banking.listAccounts(): Promise<Page<BankAccount>>
// Transactions, newest first, for one account or across every account.
banking.listTransactions(options?: ListTransactionsOptions): Promise<Page<BankTransaction>>
// One transaction by id. Using this narrows which banks can serve the task.
banking.getTransaction(id: string): Promise<BankTransaction>
// Monthly statements for one account.
banking.listStatements(options: ListStatementsOptions): Promise<Page<BankStatement>>
// The connected business's identity.
banking.getOrganization(): Promise<BankOrganization>
// The account the user picks in Settings.
bankAccount(options: BankAccountOptions): PickedBankAccount
```calendar
```typescript
// Calendar — one surface over Google Calendar and Outlook Calendar (OAuth). The
// service is resolved from the account bound to this task; task code never
// names one.
// ── Reading ──────────────────────────────────────────────────────────
await calendar.listEvents(options?: { calendar?: CalendarRef, after?: Date, before?: Date, limit?: number, cursor?: string }): Promise<Page<CalendarEvent>> // soonest first. `after` defaults to now and `before` to 30 days later, so a bare listEvents() is "what is coming up"; limit defaults to 20; the page carries `nextCursor` when more match
await calendar.getEvent({ id: string, calendar?: CalendarRef }): Promise<CalendarEvent> // the only read that returns `body`
await calendar.searchEvents({ query: string, limit?: number, cursor?: string }): Promise<Page<CalendarEvent>> // matches the event TITLE, on both services
await calendar.listCalendars(): Promise<Page<CalendarRef>>
// The user-configurable calendar picker. ONLY when the task works on a calendar
// that is NOT the account's default: a shared team calendar, a room, a second
// calendar the user keeps. A task about the user's own schedule passes no
// `calendar` at all, which is the default on both services and needs no setup.
calendarRef({ label: string, description: string }): CalendarInput // both required; import { calendarRef } from "faber-connectors"
await calendar.findFreeTime({ attendees: string[], start: Date, end: Date, durationMinutes: number, workingHours?: { start: number, end: number } }): Promise<FreeTimeSlot[]> // attendees are addresses; include the user's own. workingHours are 0-23 in the run's timezone
// ── Writing ──────────────────────────────────────────────────────────
// A timed event takes Date instants for start/end:
await calendar.createEvent({ title: string, start: Date, end: Date, body?: string, location?: string, attendees?: string[], onlineMeeting?: boolean, calendar?: CalendarRef }): Promise<CalendarEvent>
// An all-day event is a SEPARATE shape: set allDay and use YYYY-MM-DD calendar dates (end is exclusive — the day after the last day). No time, no timezone:
await calendar.createEvent({ title: string, allDay: true, start: "2026-06-16", end: "2026-06-17", body?: string, location?: string, attendees?: string[], calendar?: CalendarRef }): Promise<CalendarEvent>
await calendar.updateEvent({ id: string, title: string, newTitle?: string, start?: Date, end?: Date, body?: string, location?: string, attendees?: string[], calendar?: CalendarRef }): Promise<CalendarEvent> // for an all-day event, set allDay: true with YYYY-MM-DD start/end
await calendar.deleteEvent({ id: string, title: string, calendar?: CalendarRef }): Promise<void>
await calendar.respondToEvent({ id: string, title: string, response: "accepted" | "declined" | "tentative", calendar?: CalendarRef }): Promise<void>
interface CalendarEvent {
id: string;
title: string; // The event title on both services. NOT `summary` (Google's word) or `subject` (Outlook's).
start: string; // ISO 8601 instant for a timed event, "YYYY-MM-DD" for an all-day one. Check `allDay` before parsing.
end: string; // Same two shapes. For an all-day event this is EXCLUSIVE — the day after the last day.
allDay: boolean;
location?: string;
body?: string; // Plain text. Only `getEvent` fetches it — a list read leaves it absent on both services.
attendees: EventPerson[]; // Everyone invited, organizer included. Empty for an event with no guests.
organizer?: EventPerson;
link?: string; // The event's page, for a message that links to it.
cancelled: boolean; // The organizer cancelled it. Only Outlook returns one from a read; check it anyway, you do not know which service is behind the account.
myResponse?: "needsAction" | "accepted" | "declined" | "tentative"; // How the user answered. ABSENT on an event with no guests, which owes no answer — `undefined` is not `needsAction`.
busy?: boolean; // Whether the event holds the time. Absent on Outlook when the service will not say; `busy !== false` is the safe reading.
calendar?: CalendarInput; // Which calendar it is on. Pass it back as `calendar` on a later write.
provider: "google-calendar" | "outlook-calendar";
}
interface EventPerson {
email: string;
name?: string; // Absent when the service returned only an address.
response?: "needsAction" | "accepted" | "declined" | "tentative";
}
// What a call SUPPLIES: an id, plus a name when the caller has one. A full
// `CalendarRef` from a read satisfies it, and so does `calendarRef({ label })`.
interface CalendarInput {
id: string;
name?: string;
}
// What a read RETURNS. `listCalendars` is where `primary` and `writable` come
// from; an event carries only `CalendarInput`, because a read knows which
// calendar it is on and not whether you may write there.
interface CalendarRef {
id: string; // Opaque provider handle. Pass the whole calendar, never a bare id.
name: string;
primary: boolean; // The account's default calendar.
writable: boolean; // False for a calendar shared with this account read-only.
}
interface FreeTimeSlot {
start: string; // ISO 8601
end: string; // At least durationMinutes after `start`, and often longer — read it as the room available, not as the meeting.
}
```chat
```typescript
// Chat: one surface over Slack and Microsoft Teams conversations. The account
// is a managed setting and the task never names a service. Every method works
// on both, so a task written against this installs on either.
// The conversations this account is in, most recent first. Direct and group
// conversations only: never a channel.
await chat.listConversations({ limit?: number }): Promise<Page<ChatConversation>>
// One conversation's messages, newest first. ONE call per conversation.
await chat.listMessages({ conversation: ChatConversation, limit?: number, after?: Date, before?: Date }): Promise<Page<ChatMessage>>
// The replies under one message. On Teams a chat does not thread, so the answer
// is the message alone.
await chat.getThread({ conversation: ChatConversation, message: ChatMessageRef, limit?: number }): Promise<Page<ChatMessage>>
// Answer in a conversation, as the connected person. Plain text.
await chat.reply({ label: string, conversation: ChatConversation, text: string, replyTo?: ChatMessageRef }): Promise<PostedReply>
interface ChatConversation {
id: string; // Opaque. Never build one.
name: string; // Who it is with, or the topic where there is one.
direct: boolean; // Two people, rather than more.
participantCount?: number; // Absent where the service does not say.
link?: string; // Its page in Slack or Teams.
provider: "slack" | "teams";
}
// A message id means nothing without its conversation, so a ref is both.
interface ChatMessageRef { conversation: string; id: string }
interface ChatMessage extends ChatMessageRef {
text: string; // Plain text on both, markup converted.
original: string; // The same body with the service's markup left in.
sentAt: string; // ISO 8601 instant.
author?: { id?: string; name?: string };
fromMe: boolean; // Whether the connected account wrote it.
replyCount?: number; // Slack only; a Teams chat has no replies.
link?: string;
provider: "slack" | "teams";
}
interface PostedReply extends ChatMessageRef {
provider: "slack" | "teams";
link?: string;
}
```csv
```typescript
// CSV - parse, read, and create CSV files
type CellValue = string | number | boolean;
csv.parse({ text: string, delimiter?: string }): string[][] // synchronous
await csv.read({ path: string, delimiter?: string }): Promise<string[][]>
await csv.create({ values: CellValue[][], path: string, delimiter?: string }): Promise<CsvWriteResult>
interface CsvWriteResult {
path: string; // where the file was written — pass to a later read or upload
}
```docs
```typescript
// Docs — one surface over Google Docs and Notion pages. The destination is a
// picked setting and it carries which service it is on, so task code never names
// one.
await docs.create({ destination: DocsDestination, title: string, body?: string }): Promise<DocRef>
await docs.append({ doc: DocRef, body: string }): Promise<void>
await docs.read({ doc: DocRef }): Promise<string> // markdown
await docs.archive({ doc: DocRef }): Promise<void> // to the trash on both, Drive's and Notion's, recoverable, destroys nothing
await docs.rename({ doc: DocRef, title: string }): Promise<DocRef>
await docs.comment({ doc: DocRef, text: string }): Promise<void>
await docs.listComments({ doc: DocRef, limit?: number }): Promise<Page<DocComment>> // CAPPED: read `.items`, raise `limit`, no cursor
interface DocComment { id: string; text: string; author?: string; createdAt?: string }
// Notion-only fields, in a block named for it. Passing one locks the task to
// Notion. Most of what Notion has beyond a document, databases, typed
// properties, block edits, is not a document capability and stays on `notion`.
interface DocsProviderExtras {
notion?: { emoji?: string; match?: string }; // emoji: page icon (create). match: keep only blocks matching this regex (read).
}
// The destination comes from a setting, never from task code:
const reports = docsDestination({
label: "Where reports go",
description: "The folder or page weekly reports are written to",
});
const doc = await docs.create({
label: "Write the report",
destination: reports,
title: `Week of ${weekOf}`,
body: `# Summary\n\n${summary}\n\n- ${wins.join("\n- ")}`,
});
interface DocsDestination {
provider: "google-docs" | "notion"; // Set by the picker.
id: string; // Opaque. Never build one.
name: string;
}
interface DocRef {
provider: "google-docs" | "notion";
id: string;
title: string;
link?: string; // The document's page, for a message that links to it.
}
``````typescript
// Email — one surface over Gmail and Outlook (OAuth). The provider is resolved
// from the mailbox bound to this task; task code never names one.
// ── Reading ──────────────────────────────────────────────────────────
await email.listMessages(options?: { after?: Date, before?: Date, from?: string, fromDomain?: string, subject?: string, unread?: boolean, hasAttachments?: boolean, excludeMailingList?: boolean, folder?: EmailFolderRef, inboxOnly?: boolean, limit?: number, cursor?: string }): Promise<Page<EmailMessage>> // after/before are Date instants bounding when messages were received; `folder` reads one folder by name (implies inboxOnly: false); `from` is the sender's whole address and `subject` matches whole words; `unread`/`hasAttachments` are three-state (true, false, or omit for both); `excludeMailingList: true` drops list mail; limit defaults to 20; the page carries `nextCursor` when more match
await email.countMessages(options?: /* same filters as listMessages */): Promise<{ count: number, exact: boolean }> // how many match, fetching none. `exact` is false on Gmail (it estimates) and true on Outlook (it counts) — check it before quoting the number
await email.getMessage({ id: string }): Promise<EmailMessageDetail>
await email.getMessages({ ids: string[] }): Promise<EmailMessageDetail[]> // batch fetch
await email.getThread({ threadId: string }): Promise<EmailThread> // every message in one conversation, oldest first, from every folder including Sent; `threadId` is what sendMessage answers with and what a trigger payload carries; each message is an EmailMessageDetail plus `fromMe: boolean`, true when this mailbox sent it
await email.searchMessages({ query: string, limit?: number, cursor?: string }): Promise<Page<EmailMessage>> // provider search syntax, any folder
await email.listAttachments({ messageId: string }): Promise<Page<EmailAttachment>>
await email.getAttachment({ messageId: string, attachmentId: string }): Promise<Uint8Array> // raw bytes
// ── Classifying a message ────────────────────────────────────────────
// Deterministic, no fetch, no model call, safe on a list read. Both take the
// message itself, not an id.
email.isAutomated(message: EmailMessage): boolean // generated rather than written: calendar invites and their auto-responses, out-of-office, bounces, no-reply senders, bulk mail
email.isEventCancellation(message: EmailMessage): boolean // this message says a meeting is off. Every cancellation is also isAutomated, so test this BEFORE dropping automated mail
email.addressOf(from: string): string // the bare lowercased address out of a SINGLE-address header. `message.from`, never `to` or `cc` — those are comma-separated and come back whole
email.isForward(message: EmailMessage | EmailMessageDetail): boolean // checks the subject AND, when the body is present, the forwarded-message separator
email.isSelfSend(message: EmailMessage): boolean // the sender mailed nobody but themselves: a note to self, not correspondence
email.isMostlyQuoted(message: EmailMessageDetail): boolean // the body is overwhelmingly quotation, or opens on a quote. Needs the body
email.splitQuoted(body: string): { unique: string; quoted: string } // the half the sender wrote, and the history they quoted under it
email.isWorthVoiceSample(message: EmailMessageDetail): boolean // enough of the sender's OWN words to show how they open and close
email.isWorthKnowledgeSample(message: EmailMessageDetail): boolean // enough in the whole exchange for a durable fact to come from
// ── Sending ──────────────────────────────────────────────────────────
await email.sendMessage({ to?: string, subject: string, content: Content[], replyTo?: EmailMessage, replyAll?: boolean, attachments?: FileRef[], cc?: string, bcc?: string }): Promise<SentMessage> // the answer names the conversation it landed in, which is what lets a task that sends first recognise the reply later; a task that only sends can ignore it
await email.createDraft({ to?: string, subject: string, content: Content[], replyTo?: EmailMessage, replyAll?: boolean, attachments?: FileRef[], cc?: string, bcc?: string }): Promise<EmailDraft>
await email.listDrafts({ limit?: number, cursor?: string }): Promise<Page<EmailDraft>>
// ── Filing ───────────────────────────────────────────────────────────
await email.listFolders({ limit?: number, cursor?: string }): Promise<Page<EmailFolder>> // Gmail labels and Outlook folders are both folders here
await email.createFolder({ name: string }): Promise<EmailFolder>
await email.moveToFolder({ id: string, subject: string, from: string, folder: EmailFolderRef }): Promise<void>
await email.moveMessagesToFolder({ messages: EmailMessage[], folder: EmailFolderRef }): Promise<void>
// ── Message state ────────────────────────────────────────────────────
await email.markAsRead({ id: string, subject: string, from: string }): Promise<void>
await email.markMessagesAsRead({ messages: EmailMessage[] }): Promise<void>
await email.markAsUnread({ id: string, subject: string, from: string }): Promise<void>
await email.archive({ id: string, subject: string, from: string }): Promise<void>
await email.archiveMessages({ messages: EmailMessage[] }): Promise<void>
await email.trash({ id: string, subject: string, from: string }): Promise<void>
await email.trashMessages({ messages: EmailMessage[] }): Promise<void>
// Drafting or sending a REPLY: pass `replyTo: msg` — the EmailMessage you read.
// Do NOT hand-roll the recipients. `replyTo` gives you, for free:
// - threading. Without it the draft is a new standalone conversation and the
// user never sees it in the thread they're reading. An "Re: …" subject does
// NOT thread on its own.
// - reply-all by default: `to` = the sender, `cc` = everyone else who was on
// the original, minus this account. Pass `replyAll: false` to answer only
// the sender.
// - the quoted original below your text, the way every mail client writes a
// reply. Pass the message you READ (getMessage carries the body).
// - the subject: pass the original's, and `Re: ` is added for you (exactly
// once, however many the original already carried).
const msg = await email.getMessage({ id });
await email.createDraft({
subject: msg.subject, // "Re: " is added — don't write it yourself
replyTo: msg, // to + cc are derived — omit them
content: ["…"],
});
// Explicit `to` / `cc` / `bcc` always override what `replyTo` derives. `bcc` is
// never derived (you cannot see who else was blind-copied).
interface EmailMessage {
id: string;
threadId: string; // Gmail thread / Outlook conversation.
rfcMessageId: string; // RFC `Message-ID`. "" on an Outlook list read — pass the whole message as `replyTo` rather than reading this.
subject: string; // Already extracted from headers — do NOT look for `payload.headers[]`.
from: string; // Pre-formatted string, NOT a nested object.
to: string;
cc: string; // "" when nobody was copied.
date: string; // ISO 8601, when the MAILBOX received it. Not the sender's Date header — sort and window on this.
snippet: string;
unread: boolean;
hasAttachments: boolean;
mailingList: boolean; // The sender addressed a list, not a person (RFC `List-Unsubscribe`). Same header on both providers.
folders: string[]; // Folder NAMES. Several on Gmail (labels), at most one on Outlook.
provider: "gmail" | "outlook";
}
interface EmailMessageDetail extends EmailMessage {
body: string; // Plain text. Already decoded.
bodyHtml?: string;
}
interface EmailThreadMessage extends EmailMessageDetail {
fromMe: boolean; // This mailbox wrote it. `from` alone cannot tell you: Gmail returns the header verbatim, Outlook the bare address, and a mailbox may send under an alias.
}
interface EmailThread { // What getThread RETURNS.
threadId: string; // The id you passed.
messages: EmailThreadMessage[]; // Oldest first, across every folder including Sent, so both sides of the exchange are here.
}
interface SentMessage { // What sendMessage ANSWERS with. Every field is optional: a provider may answer with none, and a send that worked must not fail for want of a receipt.
threadId?: string; // The conversation it landed in, in the same terms the `new_email` trigger payload carries, so a reply arriving later matches with no mapping table. Absent on a preview.
messageId?: string; // This message within that conversation.
ref?: string; // The id this run gave the send. Always present, and the handle that names the send even when there is no threadId yet.
}
// The user-configurable folder picker. ONLY for a folder the USER made. A
// well-known folder is named directly and must stay that way: `{ name: "sent" }`
// resolves from a table with no request, on either provider, in any display
// language. Canonical names: inbox, sent, drafts, trash, spam, archive
// (Outlook only, Gmail has no Archive label).
emailFolder({ label: string, description: string }): EmailFolderRef // both required; import { emailFolder } from "faber-connectors"
interface EmailFolderRef { // What you PASS. A name is all it takes.
name: string; // "inbox", "sent", "drafts", "trash", "spam", "archive" work on every
// mailbox. Any other name is matched against the mailbox's own folders.
id?: string; // Set by `emailFolder({ label })` and carried by any folder a read returned.
} // When present it is used directly, so a PICKED folder survives a rename.
interface EmailFolder extends EmailFolderRef { // What listFolders/createFolder RETURN.
id: string; // The provider's own handle.
name: string;
system: boolean; // Provider-managed (Inbox, Trash) rather than user-made.
unread?: number;
total?: number;
}
interface EmailAttachment {
id: string;
name: string;
mimeType: string;
size: number;
inline: boolean; // embedded in the body (a signature logo, a pasted screenshot) rather than a file the sender attached
}
interface EmailDraft {
id: string;
messageId?: string;
}
```excel
```typescript
// Excel - read and create .xlsx files
type CellValue = string | number | boolean;
await excel.read({ path: string, sheet?: string }): Promise<CellValue[][]>
await excel.create({ values: CellValue[][], path: string, sheet?: string }): Promise<ExcelWriteResult>
interface ExcelWriteResult {
path: string; // where the file was written — pass to a later read or upload
}
```faber-mail
```typescript
// Faber Mail - email the signed-in user. No OAuth needed.
// Always sends from the Faber sender (e.g. `Faber Mail <fabs@getfaber.co>`)
// to the user's own address. There is no `to` field: the user is the addressee.
await faberMail.send({ subject: string, content: Content[], attachments?: FileRef[], cc?: string }): Promise<void> // body is structured Content (see "Rich output: the Content document"); attach files (e.g. a pdf.create result) via attachments; cc copies other people on the user's email ("sam@acme.com, Dana <dana@acme.com>", up to 10)
```files
```typescript
// Files — one surface over Google Drive and OneDrive/SharePoint. The folder is a
// picked setting and it carries which service it is on, so task code never names
// one.
await files.list({ folder: FolderRef, mimeType?: string, limit?: number, cursor?: string }): Promise<Page<FileItem>>
await files.search({ query: string, folder: FolderRef, mimeType?: string, limit?: number }): Promise<Page<FileItem>>
await files.get({ file: FileItem }): Promise<FileItem>
await files.download({ file: FileItem }): Promise<string> // text
await files.upload({ content: FileRef, folder: FolderRef }): Promise<FileItem>
await files.createFolder({ name: string, parent: FolderRef }): Promise<FileItem>
await files.update({ file: FileItem, newName?: string, content?: FileRef }): Promise<FileItem>
await files.move({ file: FileItem, destination: FolderRef }): Promise<FileItem>
await files.copy({ file: FileItem, newName?: string, folder?: FolderRef }): Promise<FileItem>
await files.trash({ file: FileItem }): Promise<void> // recoverable on both
// Google Drive only. Using one of these locks the task to Drive.
await files.delete({ file: FileItem }): Promise<void> // no recycle bin
await files.comment({ file: FileItem, text: string }): Promise<void>
await files.listComments({ file: FileItem, limit?: number }): Promise<Page<FileComment>> // CAPPED: read `.items`, raise `limit`, no cursor
// The folder comes from a setting, never from task code:
const filing = filesDestination({
label: "Filing folder",
description: "Where the files that arrive get saved, one file each",
});
const saved = await files.upload({
label: "Save the file",
content: artifact,
folder: filing,
});
// `saved.link` opens it, on either service.
interface FolderRef {
provider: "google-drive" | "onedrive"; // Set by the picker.
id: string; // Opaque. Never build one.
name: string;
}
interface FileItem {
provider: "google-drive" | "onedrive";
id: string;
name: string;
mimeType?: string; // Absent on a folder.
size?: number; // Bytes.
modifiedTime?: string;
link: string; // The file's page, for a message that links to it.
isFolder: boolean;
}
interface FileComment { id: string; text: string; author?: string; createdAt?: string; resolved?: boolean }
```google-business
```typescript
type StarRating = "ONE" | "TWO" | "THREE" | "FOUR" | "FIVE";
interface BusinessLocation {
locationId: string; // bare id, no prefix
title: string; // "Smith & Co, Oakland"
reviewLink: string; // where a customer leaves a review
mapsLink: string;
postalCode: string; // "" for a service-area business with no public address
city: string;
latitude: number | null; // null for a location Google has not placed
longitude: number | null;
}
interface Review {
reviewId: string;
locationId: string;
locationTitle: string;
reviewerName: string | null; // null when the reviewer is anonymous
isAnonymous: boolean;
starRating: StarRating | null;
comment: string | null; // null for a rating-only review
createTime: string; // RFC3339
updateTime: string; // RFC3339
reply: { comment: string; updateTime: string } | null;
}
/** Newest updateTime seen per location, keyed by locationId. */
type Watermarks = Record<string, string>;
googleBusiness.listLocations(): Promise<Page<BusinessLocation>>
googleBusiness.listReviews(opts?: {
since?: Watermarks;
locationIds?: string[];
}): Promise<Page<Review>> // reviews are in `.items`; there is no page after it
googleBusiness.nextWatermarks(previous: Watermarks, reviews: Review[]): Watermarks
googleBusiness.findReviewer(name: string, reviews: readonly Review[]): Review[]
googleBusiness.getReviewLink(locationId: string): Promise<string>
googleBusiness.replyToReview(options: {
label: string;
locationId: string;
reviewId: string;
comment: string;
}): Promise<void>
googleBusiness.deleteReviewReply(options: {
label: string;
locationId: string;
reviewId: string;
}): Promise<void>
```google-docs
```typescript
// A document is a `GoogleFile`, never a bare id. Get one from
// `googleFile({ label, description })` (the user-picked setting), or `docs.create(...)`
// (which returns the new doc), or the google-drive connector's searchFiles
// (mimeType "application/vnd.google-apps.document").
interface GoogleFile { // the picker handle; `name` is the doc title
id: string;
name: string;
mimeType?: string;
}
// ── Reads ────────────────────────────────────────────────────────────
// ── Writes ──────────────────────────────────────────────────────────
await googleDocs.insertText({ file: GoogleFile, text: string, index?: number }): Promise<void> // index defaults to 1 (document start)
await googleDocs.replaceText({ file: GoogleFile, find: string, replaceWith: string, matchCase?: boolean }): Promise<void>
await googleDocs.deleteContentRange({ file: GoogleFile, startIndex: number, endIndex: number }): Promise<void>
interface GoogleDoc {
documentId: string;
title: string;
body?: GoogleDocBody; // Range operations address this by index. `docs.read` returns a document as text.
}
interface GoogleDocBody {
content: GoogleDocElement[];
}
interface GoogleDocElement {
startIndex?: number;
endIndex?: number;
paragraph?: {
elements: Array<{
textRun?: { content: string };
}>;
};
table?: unknown;
sectionBreak?: unknown;
}
```google-drive
```typescript
// A file is the `GoogleFile` object, never a bare id. Get one from
// `googleFile({ label, description })` (the user-picked setting), a read (`getFile`,
// `listFiles`, `searchFiles`), or a write result's `file`. NEVER hardcode a
// file id. `listFiles`/`searchFiles` only see files this task already has
// access to (picked or app-created) — not the whole Drive. A folder is picked
// the same way a file is: `googleFile({ label, description })`, with the build
// opening a picker that offers folders. Never guess one via `searchFiles`.
// ── Reads ────────────────────────────────────────────────────────────
await googleDrive.listFiles(options?: { folderId?: string, query?: string, mimeType?: string, limit?: number, cursor?: string }): Promise<Page<GoogleFile>>
await googleDrive.getFile({ file: GoogleFile }): Promise<GoogleFile> // refresh metadata
await googleDrive.searchFiles({ query: string, mimeType?: string, limit?: number, cursor?: string }): Promise<Page<GoogleFile>>
await googleDrive.downloadFile({ file: GoogleFile }): Promise<string> // content as text; Workspace docs export as plain text
// ── Writes ──────────────────────────────────────────────────────────
await googleDrive.createFolder({ name: string, parentFolder?: GoogleFile }): Promise<GoogleFile> // pass the parent folder (from listFiles/search); omit for a root-level folder
await googleDrive.uploadFile({ content: FileRef, parentFolder?: GoogleFile }): Promise<GoogleFile> // pass the destination folder (from listFiles/search); omit to upload to My Drive root
await googleDrive.updateFile({ file: GoogleFile, newName?: string, content?: FileRef }): Promise<GoogleFile> // rename, or replace contents
await googleDrive.moveFile({ file: GoogleFile, destinationFolderId: string, destinationFolderName: string }): Promise<GoogleFile>
await googleDrive.copyFile({ file: GoogleFile, newName?: string, parentFolder?: GoogleFile }): Promise<GoogleFile> // pass the destination folder (from listFiles/search); omit to copy in place
await googleDrive.trashFile({ file: GoogleFile }): Promise<void> // move to trash (recoverable)
await googleDrive.listComments({ file: GoogleFile, limit?: number }): Promise<Page<GoogleDriveComment>>
await googleDrive.addComment({ file: GoogleFile, text: string }): Promise<void> // a Google Doc's comments live on the Drive FILE, not in the Docs API
interface GoogleDriveComment { id: string; text: string; author?: string; createdAt?: string; resolved?: boolean }
await googleDrive.deleteFile({ file: GoogleFile }): Promise<void> // permanent
interface GoogleFile { // the picker handle and the read-response shape, one type
id: string;
name: string;
mimeType?: string; // String; present on read responses.
size?: string; // String, not number — Drive API convention.
createdTime?: string; // ISO 8601.
modifiedTime?: string; // ISO 8601.
parents?: string[]; // Parent folder IDs.
webViewLink?: string;
}
```google-sheets
```typescript
// Google Sheets - read and write Google Sheets via API
type CellValue = string | number | boolean;
// A spreadsheet is a `GoogleFile`, never a bare id. Get one from
// `googleFile({ label, description })` (the user-picked setting), `googleSheets.createSheet(...)`,
// or the google-drive connector.
await googleSheets.getRows({ file: GoogleFile, range: string }): Promise<CellValue[][]>
await googleSheets.getSheet({ file: GoogleFile, sheet?: string }): Promise<SheetInfo>
await googleSheets.appendRows({ values: CellValue[][], file: GoogleFile, range: string }): Promise<void>
await googleSheets.updateRows({ values: CellValue[][], file: GoogleFile, range: string }): Promise<void>
await googleSheets.addChart(options: AddChartOptions): Promise<SheetChartRef> // options.file is a GoogleFile; `title` is the chart's title
await googleSheets.createSheet({ title: string, headers: string[] }): Promise<GoogleFile> // returns the new spreadsheet as a GoogleFile — pass it straight to the next action
interface GoogleFile { // the picker handle (`googleFile({ label, description })`); read responses add Drive metadata
id: string;
name: string;
mimeType?: string;
}
interface SheetInfo {
spreadsheetId: string;
title: string;
sheets: Array<{
sheetId: number;
title: string;
rows: number; // bounding box of populated cells (NOT grid size)
cols: number; // bounding box of populated cells (NOT grid size)
}>;
}
interface SheetChartRef {
spreadsheetId: string;
chartId: number;
}
type AddChartOptions =
| { chartType: "pie"; file: GoogleFile; title: string; sourceSheetId: number;
labelRange: string; valueRange: string; anchorCell: string; anchorSheetId?: number;
valuesPreview?: CellValue[][] | Array<{ label: CellValue; value: CellValue }> }
| { chartType: "bar" | "column" | "line"; file: GoogleFile; title: string;
sourceSheetId: number; domainRange: string; seriesRanges: string[];
anchorCell: string; anchorSheetId?: number; valuesPreview?: CellValue[][] };
```http
```typescript
// HTTP - for any API not covered by a dedicated connector
await http.fetch(url: string, init?: RequestInit, options?: {
label?: string; // required for a write
secretHeaders?: Record<string, string>; // credentials, e.g. { Authorization: "Bearer {{STRIPE_KEY}}" }
okStatuses?: number[];
}): Promise<Response>
```hubspot
```typescript
interface HubSpotCrmObject {
objectType: string; // stamped by the connector — records are self-describing
id: string;
properties: Record<string, string | null>;
createdAt: string;
updatedAt: string;
archived: boolean;
}
// Anywhere a method takes `record` / `from` / `to` / `associateTo`, pass a
// HubSpotCrmObject you already read — or a bare { objectType, id }.
type RecordRef = { objectType: string; id: string; properties?: Record<string, string | null> };
interface HubSpotPropertyDefinition { name: string; label: string; type: string; fieldType: string; description?: string; groupName?: string; hidden?: boolean; options?: { label: string; value: string }[] }
interface HubSpotOwner { id: string; email: string; firstName?: string; lastName?: string; userId?: number }
interface HubSpotAssociation { toObjectId: string; associationTypes: { category: string; typeId: number; label?: string }[] }
interface HubSpotAssociationLabel { category: string; typeId: number; label: string | null }
interface HubSpotPipeline { id: string; label: string; displayOrder: number; stages: { id: string; label: string; displayOrder: number }[] }
// objectType is "contacts" | "companies" | "deals" | "tickets" | "notes" |
// "tasks" | a custom object id. properties defaults to a per-type set; pass an
// array to choose, or "all" for every field.
//
// `"all"` is for a portal you know is small. A HubSpot portal can carry several
// hundred properties per object, so "all" over a page of records is tens of
// thousands of tokens to find two fields. Name the fields you need.
// List records (limit caps at 100/page, paginated for more).
hubspot.listRecords(options: { objectType: string; limit?: number, cursor?: string; properties?: string[] | "all"; associations?: string[]; archived?: boolean }): Promise<Page<HubSpotCrmObject>>
// One record by id (or by a unique property via idProperty). Use for read-after-write.
hubspot.getRecord(options: { objectType: string; id: string; properties?: string[] | "all"; associations?: string[]; idProperty?: string }): Promise<HubSpotCrmObject>
// Search by free text and/or filterGroups (max 5 groups × 6 filters). Throws past 10,000 results.
hubspot.searchRecords(options: { objectType: string; query?: string; filterGroups?: { filters: { propertyName: string; operator: string; value?: string | number; values?: (string | number)[]; highValue?: string | number }[] }[]; sorts?: { propertyName: string; direction: "ASCENDING" | "DESCENDING" }[]; properties?: string[] | "all"; limit?: number, cursor?: string }): Promise<Page<HubSpotCrmObject>>
// Read many by id in one call (100/batch). Does not return associations.
hubspot.batchReadRecords(options: { objectType: string; ids: string[]; properties?: string[] | "all"; idProperty?: string }): Promise<Page<HubSpotCrmObject>>
// Records associated with `record`. Returns association ids + typeIds.
hubspot.listAssociations(options: { record: RecordRef; toObjectType: string; limit?: number, cursor?: string }): Promise<Page<HubSpotAssociation>>
// Association type definitions between two types — how to discover a typeId.
hubspot.listAssociationLabels(options: { fromObjectType: string; toObjectType: string }): Promise<Page<HubSpotAssociationLabel>>
// Property definitions (internal field names). getProperty reads one (e.g. its enum options).
hubspot.listProperties(options: { objectType: string }): Promise<Page<HubSpotPropertyDefinition>>
hubspot.getProperty(options: { objectType: string; propertyName: string }): Promise<HubSpotPropertyDefinition>
// CRM owners — the only lookup for hubspot_owner_id (assignment). Use `id`, not `userId`.
hubspot.listOwners(options?: { limit?: number, cursor?: string }): Promise<Page<HubSpotOwner>>
// Pipelines + stages — the source of valid dealstage / hs_pipeline_stage ids.
hubspot.listPipelines(options: { objectType: string }): Promise<Page<HubSpotPipeline>>
hubspot.getCurrentUser(): Promise<{ email: string; userId: string; hubId: string; hubDomain: string; scopes: string[] }>
// Create a record, optionally with inline associations. WRITE — requires `label`.
hubspot.createRecord(options: { label: string; objectType: string; properties: Record<string, string | number | boolean>; associations?: { to: RecordRef; typeId: number; category?: "HUBSPOT_DEFINED" | "USER_DEFINED" }[] }): Promise<HubSpotCrmObject>
// Update a record (sparse — only what you pass changes). Pass the record from a read. WRITE.
hubspot.updateRecord(options: { label: string; record: RecordRef; properties: Record<string, string | number | boolean> }): Promise<HubSpotCrmObject>
// Create-or-update matched on a unique property (e.g. email) — avoids duplicates. WRITE.
hubspot.upsertRecord(options: { label: string; objectType: string; idProperty: string; id: string; properties: Record<string, string | number | boolean> }): Promise<HubSpotCrmObject>
// Archive a record (soft-delete, 90-day recycle bin). WRITE.
hubspot.archiveRecord(options: { label: string; record: RecordRef }): Promise<void>
// Associate / unassociate two records. Omit typeId for HubSpot's default type. WRITE.
hubspot.associate(options: { label: string; from: RecordRef; to: RecordRef; typeId?: number; category?: "HUBSPOT_DEFINED" | "USER_DEFINED" }): Promise<void>
hubspot.removeAssociation(options: { label: string; from: RecordRef; to: RecordRef }): Promise<void>
// Create a note or task, optionally attached to a record (default association
// resolved for contacts/companies/deals/tickets). WRITE.
hubspot.createNote(options: { label: string; body: string; ownerId?: string; associateTo?: RecordRef }): Promise<HubSpotCrmObject>
// `dueDate` takes an ISO date ("2026-08-21" — due at the end of that day, in the
// user's timezone), an ISO datetime, or a Date. Omitted, the task is due at the
// end of today. `status` defaults to NOT_STARTED.
```linear
```typescript
interface LinearTeam {
id: string;
key: string; // human key shown in the UI, e.g. "ENG"
name: string;
}
interface LinearUser {
id: string;
name: string;
email: string;
}
interface LinearWorkflowState {
id: string;
name: string; // user-renamable — do NOT branch on this
type: "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled";
position: number;
}
interface LinearIssue {
id: string; // UUID
identifier: string; // human key, e.g. "ENG-123"
title: string;
priority: number; // 0 none, 1 urgent, 2 high, 3 normal, 4 low
url: string;
updatedAt: string;
dueDate?: string; // "YYYY-MM-DD", Linear's due date is a day, not an instant
state?: { name: string; type: LinearWorkflowState["type"] };
assignee?: { id: string; name: string };
description?: string; // only populated by getIssue
team?: { id: string; key: string };
project?: { id: string; name: string };
labels?: { id: string; name: string }[];
parent?: { id: string; identifier: string };
}
interface LinearProject { id: string; name: string; state: string; url: string }
interface LinearLabel { id: string; name: string }
interface LinearCycle { id: string; number: number; name?: string; startsAt: string; endsAt: string }
interface LinearComment { id: string; body: string; createdAt: string; user?: { id: string; name: string } }
// Comments on an issue, oldest first.
linear.listComments(options: { issue: string; limit?: number, cursor?: string }): Promise<Page<LinearComment>>
// Teams in the workspace. For the team a task writes to, prefer the
// `linearTeam({ label, description })` picker setting over resolving a key here — see usage.
linear.listTeams(options?: { limit?: number, cursor?: string }): Promise<Page<LinearTeam>>
// Projects. `team` narrows to projects accessible to that team, but projects can span teams.
linear.listProjects(options?: { team?: string; limit?: number, cursor?: string }): Promise<Page<LinearProject>>
// Workflow states for a team — the source of `stateId` for a `linear: { … }` block.
linear.listWorkflowStates(options: { team: string }): Promise<Page<LinearWorkflowState>>
// Issue labels, optionally scoped to a team. Labels are team-scoped: only pass
// labelIds that belong to the issue's team.
linear.listLabels(options?: { team?: string; limit?: number, cursor?: string }): Promise<Page<LinearLabel>>
// Cycles (sprints) for a team.
linear.listCycles(options: { team: string }): Promise<Page<LinearCycle>>
// The connected Linear account.
linear.getCurrentUser(): Promise<LinearUser>
// Comment on an issue (markdown body). WRITE — requires `label`.
linear.addComment(options: { label: string; issue: string; body: string }): Promise<LinearComment>
// Create a project spanning one or more teams. WRITE — requires `label`.
linear.createProject(options: { label: string; name: string; teams: string[]; description?: string; targetDate?: string }): Promise<LinearProject>
```meetings
```typescript
type MeetingsProvider = "granola";
interface MeetingPerson {
name: string;
email?: string;
organization?: string;
}
interface MeetingSummary {
id: string;
title: string;
start?: string;
end?: string;
participants?: MeetingPerson[];
}
interface Meeting extends MeetingSummary {
notes?: string;
}
interface TranscriptLine {
speaker?: string;
text: string;
start?: string;
}
interface Transcript {
meetingId: string;
lines: TranscriptLine[];
expired?: boolean;
}
interface ListMeetingsOptions {
after?: Date;
before?: Date;
cursor?: string;
}
// Meetings in a window, newest service order. Defaults to the last 30 days.
meetings.listMeetings(options?: ListMeetingsOptions): Promise<Page<MeetingSummary>>
// One meeting with its AI-written notes.
meetings.getMeeting(options: { id: string }): Promise<Meeting>
// What was said in the meeting.
meetings.getTranscript(options: { id: string }): Promise<Transcript>
```notify
```typescript
// Notify — one surface over Slack, Telegram, Microsoft Teams and Faber Mail.
// The destination is a picked setting and it carries which service it is on, so
// task code never names one. Faber Mail (the user's own email) needs nothing
// connected, so this list always has at least one place in it.
// Exactly one of `text` and `content`. `text` is a line; `content` is a document
// (see "Rich output: the Content document"), for a brief or a digest.
await notify.post({ destination: NotifyDestination, text?: string, content?: Content[], replyTo?: string, linkPreview?: boolean, ...ProviderExtras }): Promise<PostedMessage>
await notify.attach({ destination: NotifyDestination, file: FileRef, caption?: string, content?: Content[], replyTo?: string, ...ProviderExtras }): Promise<PostedMessage>
await notify.edit({ message: PostedMessage, text: string }): Promise<void>
await notify.unpost({ message: PostedMessage }): Promise<void>
await notify.react({ message: PostedMessage, emoji: string }): Promise<void> // one emoji CHARACTER: "👍", not ":thumbsup:"
// Provider-only fields, in a block named for the service they belong to.
// PASSING ONE LOCKS THE TASK TO THAT SERVICE, its setup requires it and its
// destination picker offers only that service's places. Leave them off and the
// task posts wherever the user pointed it, which is the default.
interface ProviderExtras {
slack?: { blocks?: unknown[]; asUser?: boolean; title?: string };
telegram?: { silent?: boolean; inline?: boolean }; // inline: render an image in the chat instead of attaching a file
faberMail?: { subject?: string }; // defaults to the write's own label
}
interface PostedMessage {
provider: "slack" | "telegram" | "teams" | "faber-mail";
id: string; // Opaque. Pass to `replyTo` to answer under it.
destination: NotifyDestination;
link?: string; // The message's own page, when the service returned one
parentId?: string; // The reply's parent, where the service addresses it under one
}
// The destination comes from a setting, never from task code:
const brief = notifyDestination({
label: "Where the brief goes",
description: "The channel or chat the morning brief is posted to",
});
await notify.post({
label: "Post the brief",
destination: brief,
text: `Three follow-ups today:\n${lines.join("\n")}`,
});
interface NotifyDestination {
provider: "slack" | "telegram"; // Set by the picker. Decides which service the post goes to.
id: string; // Opaque provider handle. Never build one.
name: string; // What the user picked, as they saw it ("#ops", "You on Telegram").
}
```notion
```typescript
// ── Pickers (user-configurable) ──────────────────────────────────────
// Returns the picked object — pass it straight into a notion.* action. Never hardcode an id.
notionPage({ label: string, description: string }): NotionPage // `description` is shown under the label when the user picks; import { notionPage } from "faber-connectors"
notionDatabase({ label: string, description: string }): NotionDatabase // `description` is shown under the label when the user picks; import { notionDatabase } from "faber-connectors"
// ── Discovery ────────────────────────────────────────────────────────
await notion.search(opts?: { query?: string, kind?: "page" | "database" }): Promise<NotionItem[]>
await notion.getPageInfo({ page: NotionPage }): Promise<NotionPage>
await notion.getDatabaseColumns({ database: NotionDatabase }): Promise<NotionDatabase>
// ── Reading content ──────────────────────────────────────────────────
await notion.readPageContentRaw({ page: NotionPage, match?: string }): Promise<NotionRawBlock[]> // Notion's own block JSON — only when the flat form loses something you need
await notion.queryDatabase({ database: NotionDatabase, filter?: DbFilter, sort?: DbSort, limit?: number, cursor?: string }): Promise<Page<NotionRow>> // rows as { [property]: value }
await notion.listViews({ database: NotionDatabase }): Promise<Page<NotionView>> // the database's saved views
await notion.queryView({ view: NotionView, limit?: number, cursor?: string }): Promise<Page<NotionRow>> // rows through a view's saved filter + sort
await notion.readComments({ page: NotionPage }): Promise<Page<NotionComment>>
// ── Writes ──────────────────────────────────────────────────────────
await notion.updatePage({ page: NotionPage, title?: string, properties?: Record<string, PropertyValue>, emoji?: string, label: string }): Promise<NotionPage>
await notion.appendContent({ page: NotionPage, content: NotionBlock[], after?: NotionBlock, label: string }): Promise<void> // after = insert after an already-read block; otherwise appends at the end
await notion.uploadFile({ page: NotionPage, file: FileRef, caption?: string, after?: NotionBlock, label: string }): Promise<void> // attach a task-produced file (e.g. a generated PDF) to the page; shows as an image/pdf/file block by file type
await notion.editBlock({ block: NotionBlock, text: string, checked?: boolean, label: string }): Promise<void> // rewrite a block you read (by its id); replaces its text (drops that block's bold/italic styling)
await notion.deleteBlock({ block: NotionBlock, label: string }): Promise<void> // reversible in Notion (trash)
await notion.editTableRow({ table: NotionBlock, row: number, cells: string[], label: string }): Promise<void> // table = the flat table block you read; row = 0-based index
await notion.addTableRow({ table: NotionBlock, cells: string[], after?: number, label: string }): Promise<void> // after = row index to insert after; otherwise appends
await notion.deleteTableRow({ table: NotionBlock, row: number, label: string }): Promise<void>
await notion.restorePage({ page: NotionPage, label: string }): Promise<void> // out of trash
await notion.addComment({ page: NotionPage, text: string, label: string }): Promise<void> // page-level comment
await notion.replyToComment({ comment: NotionComment, text: string, label: string }): Promise<void> // reply into an existing discussion
// ── Block constructors (pure helpers) ────────────────────────────────
notion.heading(text: string, level?: 1 | 2 | 3): NotionBlock
notion.paragraph(text: string): NotionBlock
notion.bullet(text: string): NotionBlock
notion.numbered(text: string): NotionBlock
notion.todo(text: string, checked?: boolean): NotionBlock
notion.quote(text: string): NotionBlock
notion.code(text: string, language?: string): NotionBlock
notion.divider(): NotionBlock
notion.table(rows: string[][], opts?: { hasColumnHeader?: boolean }): NotionBlock
interface NotionPage { id: string; title: string; url?: string }
interface NotionDatabase { id: string; databaseId: string; title: string; url?: string; columns?: Record<string, PropertyTypeName> } // columns = { columnName: type }, filled by getDatabaseColumns
interface NotionItem { id: string; title: string; object: "page" | "database"; url?: string }
interface NotionComment { id: string; discussionId: string; author: string; text: string; created: string; parentCommentId?: string }
interface NotionView { id: string; name: string; type: string }
type NotionRow = Record<string, unknown> // a database row, flattened to { [property]: value }; also carries the page `id` and `url`
type NotionBlock =
| { type: "heading"; level: 1 | 2 | 3; text: string; id?: string; depth?: number }
| { type: "paragraph"; text: string; id?: string; depth?: number }
| { type: "bullet"; text: string; id?: string; depth?: number }
| { type: "numbered"; text: string; id?: string; depth?: number }
| { type: "todo"; text: string; checked: boolean; id?: string; depth?: number }
| { type: "quote"; text: string; id?: string; depth?: number }
| { type: "code"; text: string; language?: string; id?: string; depth?: number }
| { type: "divider"; id?: string; depth?: number }
| { type: "table"; rows: string[][]; rowIds?: string[]; hasColumnHeader: boolean; id?: string; depth?: number }
| { type: "media"; notionType: string; url: string; caption: string; id?: string; depth?: number } // image/bookmark/file/… — read-only
| { type: "childPage"; notionType: string; title: string; id?: string; depth?: number } // a subpage — read-only
| { type: "other"; notionType: string; text: string; id?: string; depth?: number }; // unsupported read-back type
type NotionRawBlock = { id: string; type: string; has_children?: boolean; depth?: number; [key: string]: unknown }
// The ergonomic input form per property type; null clears the property.
type PropertyValue =
| string // title, rich_text, select, status, url, email, phone_number
| number // number
| boolean // checkbox
| string[] // multi_select; people (by name/email — resolved)
| { start: string; end?: string } // date (ISO)
| null;
type DbFilter = Array<{ property: string, equals?: string | number | boolean, contains?: string, before?: string, after?: string, isEmpty?: boolean }> // AND-combined
type DbSort = Array<{ property: string, direction: "asc" | "desc" }>
```numbers
```typescript
// Numbers - read and create Apple Numbers (.numbers) files
type CellValue = string | number | boolean;
await numbers.read({ path: string, sheet?: string }): Promise<CellValue[][]>
await numbers.create({ values: CellValue[][], path: string, sheet?: string }): Promise<NumbersWriteResult>
interface NumbersWriteResult {
path: string; // where the file was written — pass to a later read or upload
}
``````typescript
// PDF - render a document and return a FileRef for result()
await pdf.create(options: PdfCreateOptions): Promise<FileRef>
interface PdfCreateOptions {
name: string; // filename, e.g. "daily-news.pdf"
content: Content[]; // the document body — shared Content nodes, plus the PDF-only ones below
pageSize?: string; // default "A4"
pageOrientation?: "portrait" | "landscape";
pageMargins?: number | [number, number, number, number];
}
// `content` is the shared Content document — text, lists, tables, styling — and
// is documented in full under "Rich output: the Content document". Below is only
// what PDF adds to it, plus the one node it drops.
// PDF-only layout: side-by-side columns, stacked nodes, a forced page break
{ columns: [{ text: "Left" }, { text: "Right", align: "right" }] }
{ pageBreak: "before" }
// PDF renders no `html` node — pdfmake is not HTML. Use text, headings, lists
// and tables instead.
{ table: { headerRows: 1, widths: ["*", "auto", "auto"],
rows: [["Index", "Close", "Chg"], ["S&P 500", "5,400", "+1.2%"]] } } // `widths` is PDF-only
```postgres
```typescript
// Run a parameterized statement. Use $1, $2, … and pass values in `params` —
// never interpolate values into the SQL string.
await postgres.query(
sql: string,
params?: unknown[],
options?: { treatAs?: "read" | "write"; label?: string },
): Promise<{ rows: Record<string, unknown>[]; rowCount: number }>
// Schema introspection (reads).
await postgres.listTables(): Promise<{ schema: string; name: string }[]>
await postgres.describeTable(name: string): Promise<{ column: string; type: string; nullable: boolean }[]>
```profile
```typescript
import { profile } from "faber-connectors";
interface ProfileEntry {
path: string; // `about`, `voice`, `clients/acme`
summary: string; // what the note covers, in one line
body: string; // the note, as a document
}
interface ProfileMaterial {
label: string; // what this call remembers, in a few words
material: string; // what happened: a sentence, a message, a whole thread
path?: string; // the file to keep it in, when the task already knows
sourceDate?: string; // when the material was written, ISO 8601
}
// Files matching `query`, at most five.
profile.search(query: string): Promise<ProfileEntry[]>
// Hand over what happened. Faber decides what to keep and where to put it.
profile.remember(input: ProfileMaterial): Promise<void>
```search-performance
```typescript
interface SearchSite {
siteUrl: string; // "sc-domain:example.com" OR "https://example.com/"
permissionLevel: string; // siteOwner | siteFullUser | siteRestrictedUser | siteUnverifiedUser
}
type SearchDimension =
| "query" | "page" | "country" | "device" | "date" | "searchAppearance" | "hour";
interface SearchQueryOptions {
siteUrl: string; // from searchPerformanceSite(), never built by hand
startDate: string; // "YYYY-MM-DD", Pacific day boundaries, inclusive
endDate: string;
dimensions?: SearchDimension[];
type?: "web" | "image" | "video" | "news" | "discover" | "googleNews";
dataState?: "final" | "all" | "hourly_all"; // default "final"
aggregationType?: "auto" | "byPage" | "byProperty";
filters?: { dimension: SearchDimension; operator: string; expression: string }[];
rowLimit?: number; // 1..25000, the default is 1000
startRow?: number;
}
interface SearchRow {
keys: string[]; // one per requested dimension, in request order
clicks: number;
impressions: number;
ctr: number; // a FRACTION (0.0123), not a percentage
position: number; // 1-based average, lower is better
}
// The site a task works on. A picker setting, filled once by the user.
searchPerformanceSite(options: { label: string, description: string }): { siteUrl: string, name: string }
await searchPerformance.listSites(): Promise<Page<SearchSite>>
await searchPerformance.query(options: SearchQueryOptions): Promise<Page<SearchRow>>
// Pure. No request, no account. Use them rather than writing the same
// arithmetic: each is a fact about this data that is easy to get wrong, and
// getting it wrong is systematic rather than occasional.
interface SearchWindow { startDate: string; endDate: string; settledLagDays: number }
// A window whose numbers have stopped moving. `days` is its length (7 by
// default), `offsetDays` how much further back it sits. Use this for EVERY
// comparison instead of counting back from today.
searchPerformance.settledWindow(options?: { days?: number, offsetDays?: number }): SearchWindow
// How far a number fell, as a fraction of what it was. Never negative, and 0
// when there was nothing to fall from.
searchPerformance.declineFraction(before: number, now: number): number
// Which of the two declines this is. "clicks-fell" means it came up about as
// often and was clicked less; "impressions-fell" means it came up less.
searchPerformance.classifyDecline(before: SearchRow, now: SearchRow, options?: { threshold?: number }): "clicks-fell" | "impressions-fell" | "none"
// What share of a number the searches Google will name account for, 0 to 1.
searchPerformance.queryCoverage(total: number, named: number): number
// The rows of a `page`-grouped read, keyed by page URL.
searchPerformance.byPage(rows: SearchRow[]): Map<string, SearchRow>
```sheets
```typescript
// Sheets — one surface over Google Sheets and Excel workbooks. The spreadsheet
// is a picked setting and it carries which service it is on, so task code never
// names one.
type CellValue = string | number | boolean;
await sheets.getRows({ file: SheetsFile, range: string }): Promise<CellValue[][]>
await sheets.getSheet({ file: SheetsFile }): Promise<SheetsInfo>
await sheets.appendRows({ file: SheetsFile, range: string, values: CellValue[][] }): Promise<void>
await sheets.updateRows({ file: SheetsFile, range: string, values: CellValue[][] }): Promise<void>
await sheets.addChart({ file: SheetsFile, title: string, chartType: "pie" | "bar" | "column" | "line", sheet: string, sourceData: string, seriesBy?: "columns" | "rows", anchorCell?: string }): Promise<ChartRef>
await sheets.createSheet({ title: string, headers: string[], folder: FolderRef }): Promise<SheetsFile>
// The spreadsheet comes from a setting, never from task code:
const index = sheetsFile({
label: "Filing index",
description: "The spreadsheet that gets one row per file",
});
const existing = await sheets.getRows({ file: index, range: "A:F" });
await sheets.appendRows({
label: "Add the filing row",
file: index,
range: "A:F",
values: [[date, from, what, name, link, id]],
});
interface SheetsFile {
provider: "google-sheets" | "excel-online"; // Set by the picker.
id: string; // Opaque. Never build one.
name: string;
}
interface SheetsInfo {
provider: "google-sheets" | "excel-online";
id: string;
title: string;
// `rows` and `cols` are the bounding box of populated cells per tab, not the
// allocated grid.
sheets: { title: string; rows: number; cols: number }[];
}
interface ChartRef { provider: "google-sheets" | "excel-online"; fileId: string; chartId: string }
```slack
```typescript
// ── Channels / messages (bot token) ──────────────────────────────────
// A channel is the `SlackChannel` object, never a bare id. Get one from
// `slackChannel({ label, description })` (the user-picked setting), `slack.getChannel(...)`,
// or a message you read (`message.channel`). NEVER hardcode a channel id.
slackChannel({ label: string, description: string }): SlackChannel // the user-configurable channel picker; both required, `description` is the line shown under the label when the user picks; import { slackChannel } from "faber-connectors"
await slack.listChannels(options?: { types?: Array<"public_channel" | "private_channel" | "mpim" | "im">, limit?: number, cursor?: string, excludeArchived?: boolean }): Promise<Page<SlackChannel>>
await slack.getChannel({ id?: string, name?: string }): Promise<SlackChannel> // look up a channel by id OR name (exactly one)
await slack.getChannelHistory({ channel: SlackChannel, limit?: number, cursor?: string, after?: Date, before?: Date }): Promise<Page<SlackMessage>> // after/before are Date instants; a window returns every message in it, else pass limit for the most recent N
await slack.getThread({ channel: SlackChannel, threadTs: string, limit?: number, cursor?: string }): Promise<Page<SlackMessage>>
// ── People ──────────────────────────────────────────────────────────
// A person the TASK IS CONFIGURED WITH, chosen once by the user: who a digest
// goes to, who gets the heads-up. Pass `.id` where a call takes a user. NEVER
// write a raw `U...` id or a handle you typed into a setting.
// This is NOT how work is assigned to whoever a note happens to mention, which
// is `assignTo: { name }` on `tracker.create`, resolved per item.
slackUser({ label: string, description: string }): PickedSlackUser // the user-configurable people picker; both required; import { slackUser } from "faber-connectors"
interface PickedSlackUser { id: string; name: string }
// ── Writes ──────────────────────────────────────────────────────────
await slack.sendDm({ userId: string, userName?: string, text: string, threadTs?: string, blocks?: unknown[], unfurl_links?: boolean, asUser?: boolean }): Promise<SlackMessageRef> // DM a person (resolve userId first; userName only labels the activity entry)
await slack.startGroupDm({ userIds: string[], userNames?: string[], text: string, threadTs?: string, blocks?: unknown[], unfurl_links?: boolean, asUser?: boolean }): Promise<SlackMessageRef> // DM several people together (resolve userIds first; userNames only labels the activity entry)
await slack.createChannel({ name: string, isPrivate?: boolean, topic?: string, purpose?: string }): Promise<SlackChannel> // returns the new channel — pass it straight to an action's `channel`
await slack.inviteToChannel({ channel: SlackChannel, userIds: string[], userNames?: string[] }): Promise<void> // add people to a channel (resolve userIds first)
await slack.archiveChannel({ channel: SlackChannel }): Promise<void> // archive a channel (reversible via unarchive in Slack)
await slack.removeReaction({ channel: SlackChannel, ts: string, emoji: string, textPreview: string, asUser?: boolean }): Promise<void>
// asUser: send as the connected person ("from me") instead of the Faber app. Default false. Set it only when the description says the DM should come from the user.
// ── Users ────────────────────────────────────────────────────────────
await slack.findUsersByName({ name: string }): Promise<SlackUser[]> // people whose handle/display/real name matches; returns ALL matches (names aren't unique)
await slack.listUsers(options?: { limit?: number, cursor?: string }): Promise<Page<SlackUser>>
await slack.getUser({ userId: string }): Promise<SlackUser>
await slack.getUserByEmail({ email: string }): Promise<SlackUser>
// ── Search (user token required; on a bot token read history and filter in code) ──
await slack.searchMessages({ query: string, count?: number, sort?: "score" | "timestamp" }): Promise<Page<SlackMessage>>
await slack.listCustomEmoji(): Promise<Record<string, string>> // workspace custom emoji as name -> image URL/alias; resolve or validate a custom emoji name before reacting
interface SlackChannel {
id: string;
name: string;
isChannel?: boolean;
isGroup?: boolean;
isIm?: boolean;
isMpim?: boolean;
isPrivate?: boolean;
isArchived?: boolean;
numMembers?: number;
topic?: { value: string }; // Wrapped object — read `channel.topic?.value`.
purpose?: { value: string };
}
interface SlackUserRef { id: string; name?: string } // name resolved on read; id is the key
interface SlackMessage {
type: string;
author?: SlackUserRef; // Who sent it (absent for bot messages — see botId).
botId?: string; // Set instead of author for bot messages.
channel?: SlackChannel; // Which channel it's in (set on search results) — pass it straight back into an action.
text: string; // `<@id>` mentions are rewritten to `@name` on read.
ts: string; // Unix timestamp string — also the message's unique key.
threadTs?: string; // Set on replies; equals `ts` of the parent message.
replyCount?: number;
reactions?: Array<{
name: string; // Emoji name without colons, e.g. "thumbsup".
count: number;
users: SlackUserRef[]; // Each reactor as { id, name }.
}>;
files?: Array<{
id: string;
name: string;
mimetype: string; // Note: lowercase `mimetype` (Slack convention), NOT `mimeType`.
url_private?: string; // Authenticated URL — needs the bot token to download.
}>;
}
interface SlackUser {
id: string;
name: string;
realName?: string;
displayName?: string;
email?: string;
isBot?: boolean;
deleted?: boolean; // Deactivated. `listUsers` returns them too, so filter if you are choosing a person.
isAdmin?: boolean;
tz?: string;
statusText?: string;
statusEmoji?: string;
title?: string;
}
interface SlackMessageRef { // what a send returns — thread a reply onto `ts`
ts: string;
channelId: string;
}
```stripe
```typescript
await stripe.getBalance(): Promise<StripeBalance>
await stripe.listCharges(options?: ListChargesOptions): Promise<Page<StripeCharge>>
await stripe.getCharge(id: string): Promise<StripeCharge>
await stripe.listInvoices(options?: ListInvoicesOptions): Promise<Page<StripeInvoice>>
await stripe.getInvoice(id: string): Promise<StripeInvoice>
await stripe.listCustomers(options?: ListCustomersOptions): Promise<Page<StripeCustomer>>
await stripe.getCustomer(id: string): Promise<StripeCustomer>
await stripe.listSubscriptions(options?: ListSubscriptionsOptions): Promise<Page<StripeSubscription>>
await stripe.listPayouts(options?: ListPayoutsOptions): Promise<Page<StripePayout>>
await stripe.listBalanceTransactions(options?: ListBalanceTransactionsOptions): Promise<Page<StripeBalanceTransaction>>
interface StripeMoney {
amount: number; // minor units, verbatim from Stripe
amountDecimal: number; // whole units, safe to sum
currency: string; // lowercase ISO, e.g. "usd"
}
interface StripeAccount {
id: string; // "acct_…"
displayName?: string;
businessName?: string;
email?: string;
country?: string;
defaultCurrency?: string;
livemode?: boolean; // false when the connected key is a sandbox key
}
interface StripeBalance {
available: StripeMoney[]; // settled, one entry per currency
pending: StripeMoney[]; // not yet settled
}
type StripeChargeStatus = "succeeded" | "pending" | "failed";
interface StripeCharge {
id: string;
amount: number; // minor units
amountDecimal: number;
amountRefunded: number;
amountRefundedDecimal: number;
currency: string;
status: StripeChargeStatus | string;
paid: boolean;
refunded: boolean;
disputed: boolean;
created?: string; // ISO 8601
description?: string | null;
customerId?: string | null;
customerEmail?: string | null;
customerName?: string | null;
invoiceId?: string | null;
failureCode?: string | null; // set on a failed charge
failureMessage?: string | null;
paymentMethod?: string; // "visa ····4242"
receiptUrl?: string | null;
dashboardUrl: string;
}
type StripeInvoiceStatus = "draft" | "open" | "paid" | "uncollectible" | "void";
interface StripeInvoice {
id: string;
number?: string | null; // absent on a draft
status: StripeInvoiceStatus | string;
currency: string;
amountDue: number;
amountDueDecimal: number;
amountPaid: number;
amountPaidDecimal: number;
amountRemaining: number;
amountRemainingDecimal: number;
created?: string;
dueDate?: string;
paidAt?: string;
customerId?: string | null;
customerEmail?: string | null;
customerName?: string | null;
subscriptionId?: string | null;
periodStart?: string;
periodEnd?: string;
hostedInvoiceUrl?: string | null; // the page a customer pays on
invoicePdf?: string | null;
dashboardUrl: string;
}
interface StripeCustomer {
id: string;
email?: string | null;
name?: string | null;
description?: string | null;
created?: string;
currency?: string | null;
delinquent?: boolean | null;
balance: number;
balanceDecimal: number;
dashboardUrl: string;
}
type StripeSubscriptionStatus =
| "active" | "past_due" | "unpaid" | "canceled"
| "incomplete" | "incomplete_expired" | "trialing" | "paused";
interface StripeSubscriptionItem {
priceId?: string;
productId?: string;
nickname?: string | null;
unitAmount: number;
unitAmountDecimal: number;
currency: string;
interval?: string; // "day" | "week" | "month" | "year"
intervalCount?: number;
quantity?: number;
}
interface StripeSubscription {
id: string;
status: StripeSubscriptionStatus | string;
customerId?: string | null;
created?: string;
currentPeriodStart?: string;
currentPeriodEnd?: string;
cancelAtPeriodEnd?: boolean;
canceledAt?: string;
trialEnd?: string;
items: StripeSubscriptionItem[];
recurringTotal: number; // per the items' own interval
recurringTotalDecimal: number;
currency: string;
dashboardUrl: string;
}
type StripePayoutStatus = "paid" | "pending" | "in_transit" | "canceled" | "failed";
interface StripePayout {
id: string;
amount: number;
amountDecimal: number;
currency: string;
status: StripePayoutStatus | string;
arrivalDate?: string; // the date it lands in the bank
created?: string;
description?: string | null;
method?: string; // "standard" | "instant"
failureCode?: string | null;
failureMessage?: string | null;
dashboardUrl: string;
}
interface StripeBalanceTransaction {
id: string;
type: string; // "charge", "refund", "payout", "stripe_fee", …
reportingCategory?: string;
amount: number;
amountDecimal: number;
fee: number;
feeDecimal: number;
net: number;
netDecimal: number;
currency: string;
created?: string;
availableOn?: string;
description?: string | null;
sourceId?: string | null; // "ch_…", "re_…", "po_…"
}
interface ListChargesOptions {
customerId?: string;
start?: string; // created on or after; YYYY-MM-DD or ISO 8601
end?: string; // created on or before
limit?: number; // 1-100, default 100
cursor?: string; // opaque nextCursor from a previous page
}
interface ListInvoicesOptions {
customerId?: string;
subscriptionId?: string;
status?: StripeInvoiceStatus;
start?: string;
end?: string;
limit?: number;
cursor?: string;
}
interface ListCustomersOptions {
email?: string; // exact match
start?: string;
end?: string;
limit?: number;
cursor?: string;
}
interface ListSubscriptionsOptions {
customerId?: string;
priceId?: string;
status?: StripeSubscriptionStatus | "all" | "ended";
start?: string;
end?: string;
limit?: number;
cursor?: string;
}
interface ListPayoutsOptions {
status?: StripePayoutStatus;
arrivalStart?: string; // filters on arrival date
arrivalEnd?: string;
start?: string;
end?: string;
limit?: number;
cursor?: string;
}
interface ListBalanceTransactionsOptions {
type?: string; // "charge", "refund", "payout", "stripe_fee"
payoutId?: string; // every line that made up one payout
start?: string;
end?: string;
limit?: number;
cursor?: string;
}
```telegram
```typescript
// Telegram is the user's own chat with the Faber bot, reached as a `notify`
// destination. There is no recipient to choose and no chat id to look up: it
// always goes to that one chat. A task calls nothing on `telegram` itself.
interface TelegramMessageRef {
messageId: number;
}
```tracker
```typescript
// Tracker — one surface over Jira, Linear, Trello, Notion, Asana, monday.com,
// Airtable and HubSpot tasks. The destination is a picked setting and it carries
// which service it is on, so task code never names one.
await tracker.create({ destination: TrackerDestination, title: string, body?: string, dueDate?: Date | string, assignTo?: Person, priority?: TrackerPriority, labels?: string[], ...ProviderExtras }): Promise<CreatedItem>
await tracker.list({ destination: TrackerDestination, limit?: number, cursor?: string }): Promise<Page<TrackerItem>>
await tracker.get({ destination: TrackerDestination, id: string }): Promise<TrackerItem>
await tracker.update({ destination: TrackerDestination, id: string, title?: string, body?: string, dueDate?: Date | string, assignTo?: Person, priority?: TrackerPriority, labels?: string[], ...ProviderExtras }): Promise<UpdatedItem>
await tracker.search({ destination: TrackerDestination, query: string, limit?: number }): Promise<Page<TrackerItem>> // CAPPED, not paged: raise `limit`, there is no cursor to follow. Matches TITLES on every tracker, by token
await tracker.listPeople({ destination: TrackerDestination }): Promise<Page<Person>>
// These NARROW. Each is on the surface for every task, and using one removes
// the services that cannot serve it from the task's destination picker. That is
// not a runtime check: the user simply never gets the choice.
//
// They compose. A task that archives AND comments AND sets a priority can only
// be installed on Linear, out of eight.
await tracker.comment({ destination: TrackerDestination, id: string, text: string }): Promise<void> // every tracker except HubSpot, which exposes no comment API
await tracker.archive({ destination: TrackerDestination, id: string }): Promise<void> // Linear, Trello, HubSpot and monday.com. Jira needs a paid plan and an admin, Notion and Asana can only trash an item on a 30-day fuse, and Airtable has no archive at all
// The destination comes from a setting, never from task code:
const followUps = trackerDestination({
label: "Where follow-ups go",
description: "The team, list, or task list new follow-up items are filed in",
});
const filed = await tracker.create({
label: "File the follow-up",
destination: followUps,
title: commitment.what,
body: commitment.context,
dueDate: commitment.when,
assignTo: { name: "Bob", email: "bob@acme.com" },
});
if (!filed.assigned) {
// Ordinary, not exceptional — say so in the output rather than pretending.
}
interface TrackerDestination {
provider: "linear" | "trello" | "hubspot" | "jira" | "notion" | "asana" | "monday" | "airtable"; // Set by the picker. Decides where the work is filed.
id: string; // The place in the provider's own terms, a Linear team id, a Trello `boardId/listId`, an Airtable `baseId/tableId`. Never BUILD one; reading it is fine once your code has declared that provider.
name: string; // What the user picked, as they saw it.
}
interface Person {
name: string; // Matched first, on every provider.
email?: string; // A disambiguator when two people share a name. Often absent, and that is fine.
}
interface TrackerItem {
id: string;
title: string;
body?: string;
dueDate?: string; // ISO 8601, or absent when nothing is due.
assignee?: Person; // Absent on a list read — see below.
link?: string;
destination?: TrackerDestination;
provider: "linear" | "trello" | "hubspot" | "jira" | "notion" | "asana" | "monday" | "airtable";
}
interface CreatedItem extends TrackerItem {
assigned: boolean; // Whether `assignTo` actually landed on somebody.
}
interface UpdatedItem extends TrackerItem {
assigned?: boolean; // Present only when the call passed `assignTo`.
}
// Provider-only fields, in a block named for the provider they belong to.
// PASSING ONE LOCKS THE TASK TO THAT PROVIDER, its setup will require that
// service and its destination picker will offer only that service's places.
// Leave them off and the task works on any tracker, which is the default.
interface ProviderExtras {
linear?: { state?: string; project?: string; parent?: string };
trello?: { position?: "top" | "bottom" | number };
hubspot?: { status?: "not_started" | "completed"; taskType?: "todo" | "email" | "call"; associateTo?: { objectType: string; id: string } };
jira?: { issueType?: string; parent?: string };
notion?: { properties?: Record<string, unknown> }; // extra database columns, by name
asana?: { section?: string };
monday?: { columnValues?: Record<string, unknown> }; // extra board columns, by id
airtable?: { fields?: Record<string, unknown> }; // extra table fields, by name
}
// One vocabulary for the three trackers that have priority. Trello has none, so
// it has no such field. HubSpot has three levels, so `urgent` and `high` both
// land as HIGH there.
type TrackerPriority = "urgent" | "high" | "normal" | "low";
```trello
```typescript
// ── Picker (user-configurable) ───────────────────────────────────────
// Returns the picked board — pass its `id` into any boardId param. Never hardcode a board id.
trelloBoard({ label: string, description: string }): TrelloBoard // `description` is shown under the label when the user picks; import { trelloBoard } from "faber-connectors"
interface TrelloBoard {
id: string;
name: string;
url?: string;
closed?: boolean;
[key: string]: unknown;
}
interface TrelloList {
id: string;
name: string;
idBoard?: string;
closed?: boolean;
[key: string]: unknown;
}
interface TrelloCard {
id: string;
name: string;
desc?: string;
url?: string;
idList?: string;
idBoard?: string;
due?: string | null;
dueComplete?: boolean;
closed?: boolean;
labels?: { id: string; name: string; color?: string }[];
[key: string]: unknown;
}
interface TrelloComment {
id: string;
text: string;
memberCreator?: { id: string; fullName?: string; username?: string };
date?: string;
[key: string]: unknown;
}
interface TrelloLabel {
id: string;
name: string;
color?: string | null;
[key: string]: unknown;
}
interface TrelloChecklist {
id: string;
name: string;
idCard?: string;
checkItems?: TrelloCheckItem[];
[key: string]: unknown;
}
interface TrelloCheckItem {
id: string;
name: string;
state?: "complete" | "incomplete";
[key: string]: unknown;
}
interface TrelloMember {
id: string;
fullName?: string;
username?: string;
email?: string; // Only when Trello will say — it withholds this unless the requester has workspace privilege. Match people on fullName/username FIRST and use this to disambiguate.
[key: string]: unknown;
}
// Boards the connected account can see.
trello.listBoards(options?: { limit?: number; cursor?: string }): Promise<Page<TrelloBoard>> // limit defaults to 20; the page carries `nextCursor` when more exist
// Lists (columns) on a board.
trello.listLists(options: { boardId: string }): Promise<Page<TrelloList>>
// Comments on a card (newest first).
trello.listComments(options: { cardId: string; limit?: number }): Promise<Page<TrelloComment>> // newest first; capped, not paged; limit defaults to 20
// Labels defined on a board.
trello.listLabels(options: { boardId: string }): Promise<Page<TrelloLabel>>
// Checklists (and their items) on a card.
trello.listChecklists(options: { cardId: string }): Promise<Page<TrelloChecklist>>
// Create a board. WRITE — requires `label`.
trello.createBoard(options: { label: string; name: string; desc?: string }): Promise<TrelloBoard>
// Create a list (column) on a board. WRITE — requires `label`.
trello.createList(options: { label: string; boardId: string; name: string; pos?: string | number }): Promise<TrelloList>
// Move a card to another list (and optionally reorder). WRITE — requires `label`.
// Pass the `card` and the destination `list` (from listLists) as objects — their
// names are what the user sees in the run; the API uses their ids.
trello.moveCard(options: { label: string; card: TrelloCard; list: TrelloList; pos?: string | number }): Promise<TrelloCard>
// Comment on a card. WRITE — requires `label`.
trello.addComment(options: { label: string; cardId: string; text: string }): Promise<TrelloComment>
// Attach a board label to a card. WRITE — requires `label`.
// Pass the `card` and the `boardLabel` (from listLabels) as objects — their names
// drive the preview; the API uses their ids.
trello.addLabel(options: { label: string; card: TrelloCard; boardLabel: TrelloLabel }): Promise<{ cardId: string; labelId: string }>
// Create a checklist on a card. WRITE — requires `label`.
trello.createChecklist(options: { label: string; cardId: string; name: string }): Promise<TrelloChecklist>
// Add an item to a checklist (optionally pre-checked). WRITE — requires `label`.
trello.addChecklistItem(options: { label: string; checklistId: string; name: string; checked?: boolean }): Promise<TrelloCheckItem>
// Assign a board member to a card. WRITE — requires `label`.
// Pass the `card` and the `member` as objects — their names drive the preview;
// the API uses their ids.
trello.assignMember(options: { label: string; card: TrelloCard; member: TrelloMember }): Promise<{ cardId: string; memberId: string }>
```web
```typescript
await web.search({ query: string, maxResults?: number, after?: Date, kind?: "web" | "news" }): Promise<WebSearchResult[]> // after is floored to the day; kind defaults to "web"
await web.extract({ url: string, query?: string, maxChars?: number }): Promise<WebExtractResult>
interface WebSearchResult {
title: string;
url: string;
snippet: string;
}
interface WebExtractResult {
url: string;
provider: "tavily" | "exa";
content: string;
}
```x
```typescript
await x.searchTweets({ query: string, limit?: number, cursor?: string, sinceId?: string }): Promise<Page<Tweet>>
await x.getTweet({ id: string }): Promise<Tweet>
await x.getTweets({ ids: string[] }): Promise<Page<Tweet>> // batch, max 100
await x.getUserByUsername({ username: string }): Promise<XUser>
await x.getUserTimeline({ username: string, limit?: number, cursor?: string }): Promise<Page<Tweet>>
interface Tweet {
id: string;
text: string;
authorId?: string;
createdAt?: string;
publicMetrics?: {
likes: number;
retweets: number;
replies: number;
quotes: number;
impressions: number;
bookmarks: number;
};
}
interface XUser {
id: string;
name: string;
username: string;
description?: string;
publicMetrics?: {
followers: number;
following: number;
tweets: number;
listed: number;
};
}
interface Page<Tweet> {
items: Tweet[]; // NOT `data`, NOT `results`, NOT `tweets`.
nextCursor?: string; // present only when more matched; pass it back as `cursor`
estimatedTotal?: number;
}
```