Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

- **Webhooks** — `webhooks:event-types` / `list` / `get` / `create` / `update` /
`enable` / `disable` / `delete` / `rotate-secret` / `deliveries`. Webhooks are
owned by the API key's user, so every workspace lists the same ones; the
workspace only decides whose API credits the call uses. The signing secret is
printed once, by `create` and `rotate-secret` only (the old secret keeps
signing for 24h after a rotate). `webhooks:deliveries` is the paginated
delivery log for diagnosing why events stopped arriving.
- **Brand Knowledge** — `brand:get` / `section` / `create` / `update` / `delete` /
`source-add` / `source-delete` / `sync` / `post-settings` / `post-settings-update`,
backed by `/workspaces/{workspace_id}/brand`. `create`, `source-add` and `sync` run
the app's AI brand analysis synchronously (up to ~2 minutes), so they wait 180s
(`--timeout`) and are not auto-retried.

## 1.6.0 — content categories, approval workflows, planner share links, AI video (2026-09-17)

The public API gained five surfaces; the CLI mirrors all of them.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,24 @@ The CLI wraps these endpoints from the ContentStudio v1 public API (plus the wor
| DELETE | `/workspaces/{w}/share-links/{id}` | `planner-share-links:delete` |
| POST | `/workspaces/{w}/share-links/{id}/send-invitations` | `planner-share-links:send-invitations` |
| GET | `/workspaces/{w}/share-links/{id}/activity` | `planner-share-links:activity` |
| GET | `/workspaces/{w}/webhooks/event-types` | `webhooks:event-types` |
| GET | `/workspaces/{w}/webhooks` | `webhooks:list` |
| POST | `/workspaces/{w}/webhooks` | `webhooks:create` |
| GET | `/workspaces/{w}/webhooks/{id}` | `webhooks:get` |
| PUT | `/workspaces/{w}/webhooks/{id}` | `webhooks:update` / `webhooks:enable` / `webhooks:disable` |
| DELETE | `/workspaces/{w}/webhooks/{id}` | `webhooks:delete` |
| POST | `/workspaces/{w}/webhooks/{id}/rotate-secret` | `webhooks:rotate-secret` |
| GET | `/workspaces/{w}/webhooks/{id}/deliveries` | `webhooks:deliveries` |
| GET | `/workspaces/{w}/brand` | `brand:get` |
| POST | `/workspaces/{w}/brand` | `brand:create` |
| PATCH | `/workspaces/{w}/brand` | `brand:update` |
| DELETE | `/workspaces/{w}/brand` | `brand:delete` |
| GET | `/workspaces/{w}/brand/{section}` | `brand:section` |
| POST | `/workspaces/{w}/brand/sources` | `brand:source-add` |
| DELETE | `/workspaces/{w}/brand/sources/{id}` | `brand:source-delete` |
| POST | `/workspaces/{w}/brand/sync` | `brand:sync` |
| GET | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings` |
| PATCH | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings-update` |
| GET | `/workspaces/{w}/labels` | `labels:list` |
| GET | `/workspaces/{w}/team-members` | `team:list` |
| GET | `/workspaces/{w}/media` | `media:list` |
Expand Down Expand Up @@ -1530,6 +1548,19 @@ contentstudio --json planner-share-links:create --name "Client Review" --plan <p
contentstudio --json planner-share-links:send-invitations <id> --email a@b.c --approval-option anyone
contentstudio --json planner-share-links:activity <id> [--type comment] # Client comments/actions

# Webhooks (owned by your user — same list in every workspace; the workspace only pays the API credit)
contentstudio --json webhooks:event-types
contentstudio --json webhooks:create --url https://example.com/hook --event-type post.published --event-type post.failed # prints the secret ONCE
contentstudio --json webhooks:deliveries <id> --status failed # why did events stop?
contentstudio --json webhooks:rotate-secret <id> # old secret signs for 24h more

# Brand Knowledge (one brand per workspace; create/source-add/sync run a ~2 min AI analysis)
contentstudio --json brand:get
contentstudio --json brand:create --website-url https://acme.coffee --social-account <account_id>
contentstudio --json brand:update --brand-voice '{"tone":["Warm","Confident"]}' # a list sent replaces that list
contentstudio --json brand:source-add --file https://acme.coffee/brand-guide.pdf
contentstudio --json brand:post-settings-update --social-platform linkedin --number-of-posts 5

# Posts
contentstudio --json posts:list [--status draft] [--date-from] [--date-to] # List posts
contentstudio --json posts:get <post_id> # Read one post
Expand Down
131 changes: 126 additions & 5 deletions SKILL.md

Large diffs are not rendered by default.

131 changes: 126 additions & 5 deletions skills/contentstudio/SKILL.md

Large diffs are not rendered by default.

285 changes: 285 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4168,5 +4168,290 @@ export function getWorkspaceLimits(c: Client, workspaceId: string) {
return c.get<any>(`/workspaces/${workspaceId}/limits`);
}

// ─────────────────────────────────────────────────────────────────
// Webhooks — outbound event subscriptions.
//
// A webhook belongs to the API key's USER, not to a workspace: every
// workspace the user is a member of answers with the same webhooks. The
// `{workspace_id}` in the path only picks whose API credit pool the call
// uses (1 credit per call). The webhook object never contains the secret —
// only create and rotate-secret return it, once.
// ─────────────────────────────────────────────────────────────────

/**
* GET /workspaces/{w}/webhooks/event-types
*
* Returns `[{ value, label, group, description, payload_schema }]`. `value`
* is what `event_types[]` takes on create/update.
*/
export function listWebhookEventTypes(c: Client, workspaceId: string) {
return c.get<any[]>(`/workspaces/${workspaceId}/webhooks/event-types`);
}

/**
* GET /workspaces/{w}/webhooks
*
* Not paginated. The response is flat (`{status, message, used, limit,
* data}`), so this reads the raw envelope — unwrapping would drop `used` and
* `limit` (the per-user cap, 5).
*/
export async function listWebhooks(
c: Client,
workspaceId: string,
): Promise<{ used: number; limit: number; data: any[] }> {
const body = await c.request<any>("GET", `/workspaces/${workspaceId}/webhooks`, {
unwrap: false,
});
return {
used: Number(body?.used ?? 0),
limit: Number(body?.limit ?? 0),
data: Array.isArray(body?.data) ? body.data : [],
};
}

/** GET /workspaces/{w}/webhooks/{webhook_id} */
export function getWebhook(c: Client, workspaceId: string, webhookId: string) {
return c.get<any>(`/workspaces/${workspaceId}/webhooks/${webhookId}`);
}

/**
* POST /workspaces/{w}/webhooks
*
* Body: `url` (required, https, publicly reachable), `event_types` (required,
* ≥1), `name` (≤120), `secret` (optional `whsec_…`; omitted → generated),
* `custom_headers` (object of strings). The backend refuses internal URLs
* (`WEBHOOK_URL_NOT_ALLOWED`) and pings the URL, which must answer 2xx within
* 5s (`WEBHOOK_PREFLIGHT_FAILED`). Answers 201 with the webhook plus
* `secret` — the only time it is shown, apart from rotate-secret.
*/
export function createWebhook(
c: Client,
workspaceId: string,
body: {
url: string;
event_types: string[];
name?: string;
secret?: string;
custom_headers?: Record<string, unknown>;
},
) {
return c.post<any>(`/workspaces/${workspaceId}/webhooks`, { json: body });
}

/**
* PUT /workspaces/{w}/webhooks/{webhook_id} — partial update.
*
* Accepts `url`, `event_types`, `name`, `custom_headers`, and `status`
* (`enabled` | `disabled`). `enabled` also resumes a `disabled_by_system`
* webhook and resets its failure count. A new `url` is re-checked and pinged.
*/
export function updateWebhook(
c: Client,
workspaceId: string,
webhookId: string,
body: Record<string, unknown>,
) {
return c.put<any>(`/workspaces/${workspaceId}/webhooks/${webhookId}`, {
json: body,
});
}

/** DELETE /workspaces/{w}/webhooks/{webhook_id} — answers `data: null`. */
export function deleteWebhook(
c: Client,
workspaceId: string,
webhookId: string,
) {
return c.delete<any>(`/workspaces/${workspaceId}/webhooks/${webhookId}`);
}

/**
* POST /workspaces/{w}/webhooks/{webhook_id}/rotate-secret
*
* Returns the webhook plus the new `secret` and `previous_secret_expires_at`
* (ISO, or null when it had no secret): the old secret keeps signing
* alongside the new one for 24h.
*/
export function rotateWebhookSecret(
c: Client,
workspaceId: string,
webhookId: string,
) {
return c.post<any>(
`/workspaces/${workspaceId}/webhooks/${webhookId}/rotate-secret`,
);
}

/**
* GET /workspaces/{w}/webhooks/{webhook_id}/deliveries — paginated (flat
* Laravel metadata). One row per event, newest first, showing its latest
* attempt: `{ event_id, event_type, outcome, response_status, duration_ms,
* is_test, created_at }`. `outcome` is delivered | failed | retrying |
* skipped_out_of_credits. Kept for 30 days.
*/
export function listWebhookDeliveries(
c: Client,
workspaceId: string,
webhookId: string,
params: {
status?: "all" | "successful" | "failed";
event_type?: string;
from?: string;
to?: string;
search?: string;
page?: number;
per_page?: number;
} = {},
) {
return c.getPaginated<any[]>(
`/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries`,
params,
);
}

// ─────────────────────────────────────────────────────────────────
// Brand Knowledge — the workspace's brand (style, profile, voice, source
// materials) that ContentStudio's AI resolves server-side for generation.
// One brand per workspace. Base path /workspaces/{w}/brand.
//
// POST /brand, POST /brand/sources and POST /brand/sync run the app's brand
// analysis synchronously (up to ~2 minutes), so the commands call them with a
// BRAND_ANALYSIS_TIMEOUT_MS client and retries OFF — the built-in 5xx retry
// would re-run an analysis after a 502 BRAND_ANALYSIS_FAILED.
// ─────────────────────────────────────────────────────────────────

/** Client timeout for the slow analysis calls (create / add sources / sync). */
export const BRAND_ANALYSIS_TIMEOUT_MS = 180_000;

/** Sections `GET /brand/{section}` accepts. */
export const BRAND_SECTIONS = ["style", "profile", "voice"] as const;

/** Body of `POST /brand` and `POST /brand/sources` (at least one field). */
export interface BrandSourcesBody {
/** Website to scrape and analyse. */
website_url?: string;
/** Brand info text, ≤10000 chars. */
text?: string;
/** ≤50 public https documents (PDF/DOCX/TXT/Markdown); `name` ≤255. */
files?: { url: string; name?: string }[];
/** Account `id`s from `GET /accounts` — connected accounts of this workspace. */
social_accounts?: string[];
}

/**
* GET /workspaces/{w}/brand — the brand object. A workspace without a brand
* answers 200 with `is_set_up: false`, empty sections and null timestamps.
*/
export function getBrand(c: Client, workspaceId: string) {
return c.get<any>(`/workspaces/${workspaceId}/brand`);
}

/**
* GET /workspaces/{w}/brand/{section} — `style` | `profile` | `voice`.
* Returns `{ schema_version, is_set_up, brand_<section>, updated_at }`.
*/
export function getBrandSection(
c: Client,
workspaceId: string,
section: (typeof BRAND_SECTIONS)[number],
) {
return c.get<any>(`/workspaces/${workspaceId}/brand/${section}`);
}

/**
* POST /workspaces/{w}/brand — build the brand by AI analysis ("Build My
* Brand Knowledge"). 201 with the brand. 409 BRAND_ALREADY_EXISTS when the
* workspace already has one; 422 BRAND_INSUFFICIENT_CONTENT; 502
* BRAND_ANALYSIS_FAILED (nothing saved — retry).
*/
export function createBrand(
c: Client,
workspaceId: string,
body: BrandSourcesBody,
) {
return c.post<any>(`/workspaces/${workspaceId}/brand`, { json: body });
}

/**
* PATCH /workspaces/{w}/brand — partial update of `brand_style`,
* `brand_profile`, `brand_voice` and/or `brand_enabled`. Only fields sent
* change; a list sent replaces that list; unknown keys are rejected. Creates
* the brand (without analysis) if the workspace has none.
*/
export function updateBrand(
c: Client,
workspaceId: string,
body: Record<string, unknown>,
) {
return c.patch<any>(`/workspaces/${workspaceId}/brand`, { json: body });
}

/**
* DELETE /workspaces/{w}/brand — permanent. Returns
* `{ deleted: true, brand_assets, source_materials, logo }`. 404 BRAND_NOT_FOUND.
*/
export function deleteBrand(c: Client, workspaceId: string) {
return c.delete<any>(`/workspaces/${workspaceId}/brand`);
}

/**
* POST /workspaces/{w}/brand/sources — add sources and AI-blend each into the
* existing brand. 201 `{ added: [source…], brand }`; each added source is
* `synced` or `unreachable`. 404 BRAND_NOT_FOUND; 422 VALIDATION_ERROR (incl.
* >50 sources total on `source_materials`); 422 BRAND_SOURCE_ADD_FAILED.
*/
export function addBrandSources(
c: Client,
workspaceId: string,
body: BrandSourcesBody,
) {
return c.post<any>(`/workspaces/${workspaceId}/brand/sources`, { json: body });
}

/**
* DELETE /workspaces/{w}/brand/sources/{source_id} — returns
* `{ deleted: true, id, auto_reply_rules_affected: [rule names] }`.
* 404 BRAND_NOT_FOUND / BRAND_SOURCE_NOT_FOUND.
*/
export function deleteBrandSource(
c: Client,
workspaceId: string,
sourceId: string,
) {
return c.delete<any>(
`/workspaces/${workspaceId}/brand/sources/${encodeURIComponent(sourceId)}`,
);
}

/**
* POST /workspaces/{w}/brand/sync — re-analyse ALL sources and overwrite the
* style/profile/voice. No body. 404 BRAND_NOT_FOUND; 422
* BRAND_INSUFFICIENT_CONTENT; 502 BRAND_ANALYSIS_FAILED.
*/
export function syncBrand(c: Client, workspaceId: string) {
return c.post<any>(`/workspaces/${workspaceId}/brand/sync`);
}

/** GET /workspaces/{w}/brand/post-generation-settings */
export function getBrandPostSettings(c: Client, workspaceId: string) {
return c.get<any>(`/workspaces/${workspaceId}/brand/post-generation-settings`);
}

/**
* PATCH /workspaces/{w}/brand/post-generation-settings — only fields sent
* change. Nothing saves until a `social_platform` is stored or sent (422 on
* `social_platform`). Creates the brand if the workspace has none.
*/
export function updateBrandPostSettings(
c: Client,
workspaceId: string,
body: Record<string, unknown>,
) {
return c.patch<any>(
`/workspaces/${workspaceId}/brand/post-generation-settings`,
{ json: body },
);
}

// Re-export ContentStudioError for convenience in commands.
export { ContentStudioError };
Loading
Loading