A self-built CLI and companion agent skill for operating a deployed
inkreach-teable instance — configuring tables, fields, records, views, and
the project's OWN automation engine.
Why not the official 'teable' CLI:
inkreach-teable's automation is self-built with a different contract:
- conditions (not filter), fieldIds (not watchFieldIds)
- recordCondition (not recordMatchesConditions)
- webhook route /api/webhook/{baseId}/{wfId} (not the /base//workflow/ form)
- scheduledTime backend honors only timing.type + timing.interval
The official CLI targets the upstream contract and 404s/misconfigures here.
Table/field/record/view APIs ARE upstream-compatible, but automation is not.
inkreach CLI (packages/inkreach-cli/):
- Node.js + commander, zero extra runtime deps
- Uses node:http (NOT fetch — Node 24 fetch drops Authorization header)
- Commands: auth (login/status/token), base/table/field/record/view,
workflow (list/get/create/update/delete/activate/deactivate/test),
trigger set (8 types, builds correct config keys), action add/update/delete
(6 types), secret, runs (list/get/rerun), fire (button-click/webhook), sql
- --json mode for agent parsing; GET->modify->PUT whole-graph for node edits
- Verified end-to-end: create workflow -> set webhook trigger -> add script
action -> activate -> fire webhook -> runs list (succeeded)
inkreach-teable-ops skill (.agents/skills/inkreach-teable-ops/):
- SKILL.md entry: module map, routing rules, 11 key constraints (the real
contract keys), execution rules, error recovery
- guides/: cli-reference, automation-guide, tables-fields, instance-access,
development (build/restart, known gotchas)
- api-reference/: trigger-types (8 triggers, exact config), action-types
(6 actions, sandbox globals), field-types (21 types incl. Button)
- metadata.json + install.sh
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
/**
|
|
* High-level API client. Each method maps to one REST endpoint of the
|
|
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
|
*
|
|
* Base/table/field/record/view endpoints are compatible with official teable
|
|
* open-api; workflow/automation endpoints use the project's own contract.
|
|
*/
|
|
import { request, ApiError } from './request';
|
|
import { IConfig } from './config';
|
|
|
|
export class Client {
|
|
constructor(private cfg: IConfig) {}
|
|
|
|
private get headers(): Record<string, string> {
|
|
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
|
}
|
|
|
|
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
|
|
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
|
return res.data;
|
|
}
|
|
|
|
// ---- auth / user ----
|
|
getMe() {
|
|
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
|
// (/api/auth/user/me is session-only and 403s for PAT.)
|
|
return this.call<unknown>('GET', '/api/auth/user');
|
|
}
|
|
|
|
// ---- access tokens (PAT) ----
|
|
listAccessTokens() {
|
|
return this.call<unknown[]>('GET', '/api/access-token');
|
|
}
|
|
createAccessToken(body: {
|
|
name: string;
|
|
description?: string;
|
|
scopes: string[];
|
|
baseIds?: string[];
|
|
spaceIds?: string[];
|
|
hasFullAccess?: boolean;
|
|
expiredTime: string;
|
|
}) {
|
|
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
|
|
}
|
|
deleteAccessToken(id: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
|
|
}
|
|
|
|
// ---- base ----
|
|
listBases() {
|
|
// GET /api/base/access/all — lists bases the user can access.
|
|
return this.call<unknown[]>('GET', '/api/base/access/all');
|
|
}
|
|
getBase(baseId: string) {
|
|
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
|
}
|
|
|
|
// ---- table ----
|
|
listTables(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
|
}
|
|
getTable(tableId: string) {
|
|
return this.call<unknown>('GET', `/api/table/${tableId}`);
|
|
}
|
|
createTable(baseId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
|
}
|
|
updateTable(tableId: string, body: unknown) {
|
|
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
|
|
}
|
|
deleteTable(tableId: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}`);
|
|
}
|
|
|
|
// ---- field ----
|
|
listFields(tableId: string) {
|
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
|
|
}
|
|
getField(fieldId: string) {
|
|
return this.call<unknown>('GET', `/api/field/${fieldId}`);
|
|
}
|
|
createField(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
|
|
}
|
|
updateField(fieldId: string, body: unknown) {
|
|
return this.call<unknown>('PUT', `/api/field/${fieldId}`, body);
|
|
}
|
|
deleteField(fieldId: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`);
|
|
}
|
|
|
|
// ---- record ----
|
|
getRecords(tableId: string, query?: Record<string, unknown>) {
|
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
|
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
|
|
}
|
|
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
|
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (v === undefined || v === null) continue;
|
|
if (Array.isArray(v)) {
|
|
v.forEach((item) => {
|
|
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
});
|
|
} else if (typeof v === 'object') {
|
|
out[k] = JSON.stringify(v);
|
|
} else {
|
|
out[k] = String(v);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
getRecord(recordId: string, query?: Record<string, unknown>) {
|
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
|
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
|
|
}
|
|
createRecord(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
|
|
}
|
|
updateRecord(tableId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
|
|
}
|
|
updateRecordOne(recordId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
|
|
}
|
|
deleteRecord(tableId: string, body: unknown) {
|
|
return this.call<unknown>('DELETE', `/api/table/${tableId}/record`, body);
|
|
}
|
|
|
|
// ---- view ----
|
|
listViews(tableId: string) {
|
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
|
}
|
|
getView(viewId: string) {
|
|
return this.call<unknown>('GET', `/api/view/${viewId}`);
|
|
}
|
|
createView(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
|
}
|
|
updateView(viewId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
|
|
}
|
|
deleteView(viewId: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
|
}
|
|
|
|
// ---- workflow (self-built contract) ----
|
|
listWorkflows(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
|
}
|
|
getWorkflow(workflowId: string) {
|
|
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
|
|
}
|
|
/**
|
|
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
|
* route that resolves base from the workflow itself, but some auth/permission
|
|
* paths need the base-scoped route. Prefer this when baseId is known.
|
|
*/
|
|
getWorkflowInBase(baseId: string, workflowId: string) {
|
|
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
|
}
|
|
createWorkflow(baseId: string, body: { name: string; description?: string }) {
|
|
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
|
|
}
|
|
updateWorkflow(baseId: string, workflowId: string, body: { name?: string; description?: string }) {
|
|
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
|
}
|
|
deleteWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
|
}
|
|
activateWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<{ snapshotId: string }>('POST', `/api/base/${baseId}/workflow/${workflowId}/activate`);
|
|
}
|
|
deactivateWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
|
}
|
|
testWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<{ nodeResults: unknown[] }>('POST', `/api/base/${baseId}/workflow/${workflowId}/test`);
|
|
}
|
|
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
|
replaceNodes(baseId: string, workflowId: string, body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }) {
|
|
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
|
}
|
|
|
|
// ---- secrets ----
|
|
listSecrets(baseId: string, workflowId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
|
}
|
|
createSecret(baseId: string, workflowId: string, body: { name: string; description?: string; value: string }) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
|
}
|
|
updateSecret(baseId: string, workflowId: string, secretId: string, body: { name?: string; description?: string; value?: string }) {
|
|
return this.call<unknown>('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body);
|
|
}
|
|
deleteSecret(baseId: string, workflowId: string, secretId: string) {
|
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`);
|
|
}
|
|
|
|
// ---- execution history ----
|
|
listExecutions(baseId: string, workflowId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
|
}
|
|
getExecution(baseId: string, workflowId: string, executionId: string) {
|
|
return this.call<unknown>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`);
|
|
}
|
|
rerunExecution(baseId: string, workflowId: string, executionId: string) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`);
|
|
}
|
|
|
|
// ---- trigger (manual fire for testing) ----
|
|
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
|
|
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
|
|
'POST',
|
|
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
|
|
);
|
|
}
|
|
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
|
|
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
|
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
|
}
|
|
|
|
// ---- SQL (read-only) ----
|
|
sql(baseId: string, sql: string) {
|
|
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
|
|
}
|
|
}
|
|
|
|
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
|
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
|
|
export type WorkflowNodeCategory =
|
|
| 'recordCreated' | 'recordUpdated' | 'recordCondition' | 'recordCreatedOrUpdated'
|
|
| 'formSubmitted' | 'scheduledTime' | 'buttonClick' | 'webhook'
|
|
| 'script' | 'sendEmail' | 'httpRequest'
|
|
| 'createRecord' | 'updateRecord' | 'getRecords'
|
|
| 'triggerEnd';
|
|
|
|
export interface IWorkflowNodeVo {
|
|
id: string;
|
|
workflowId?: string;
|
|
type: WorkflowNodeType;
|
|
category: WorkflowNodeCategory | string;
|
|
config: Record<string, unknown> | null;
|
|
name?: string | null;
|
|
testResult?: Record<string, unknown> | null;
|
|
inputVariables?: unknown;
|
|
outputVariables?: unknown;
|
|
}
|
|
|
|
export interface IWorkflowEdgeVo {
|
|
id: string;
|
|
workflowId?: string;
|
|
sourceNodeId: string;
|
|
targetNodeId: string;
|
|
}
|
|
|
|
export interface IWorkflowVo {
|
|
id: string;
|
|
baseId: string;
|
|
name: string;
|
|
description?: string | null;
|
|
isActive: boolean;
|
|
hasDraft: boolean;
|
|
nodes: IWorkflowNodeVo[];
|
|
edges: IWorkflowEdgeVo[];
|
|
createdBy?: string;
|
|
createdTime?: string;
|
|
lastModifiedBy?: string | null;
|
|
lastModifiedTime?: string | null;
|
|
}
|
|
|
|
export { ApiError };
|