AI (Phase 7)

Krunc uses Anthropic Claude for text and a placeholder renderer for graphics. Everything the AI creates is a draft: inactive products, status = draft posts, disabled automations, media assets flagged ai_generated. Publishing, activating, pricing and deleting always require a person in the dashboard.

Configuration

VariableEffect
ANTHROPIC_API_KEYBlank → MockAIProvider (deterministic, every output labelled "(mock)"), dashboard shows an amber banner.
ANTHROPIC_MODELModel id sent to the Messages API (default claude-sonnet-5; claude-opus-5 recommended for copilot quality).
IMAGE_PROVIDERmock (GD gradient + headline placeholder, always in tests) or openai (decided 2026-09-26).
IMAGE_PROVIDER_API_KEY / OPENAI_API_KEYKey for the OpenAI Images API.
IMAGE_MODEL, IMAGE_QUALITYDefault gpt-image-2.5-sunburst, medium (low ≈ ×0.26, high ≈ ×3.9 the cost).

Plan limits and credits

tenants.limits.ai_monthly counts credits, not calls. One credit is AIService::CREDIT_MICROS ($0.03) of provider spend, and no operation costs less than one:

credits = max(1, ceil(cost_micros / 30_000))

Free 20 / creator 500 / network 5000 — so roughly $0.60 / $15 / $150 of monthly spend. A caption is one credit; a high-quality 1536×1024 image is several. Rows are weighted when they are read (SUM(GREATEST(1, CEIL(cost_micros / credit)))), so changing the constant re-weights the current month with no backfill.

Exceeding the limit returns HTTP 429. Image operations are checked before the provider is called, against ImageProvider::estimate(), because a render that cannot be afforded must not cost anybody money first; the message names the credits needed and left. A refusal records nothing. GET /dashboard/ai/usage shows credits per feature and per row.

Batch paths degrade instead of failing whole: the graphics generator keeps the presets it managed and reports where it stopped, and the onboarding wizard, server-setup kit and copilot keep their drafts when the launch graphic cannot be paid for.

Every generation inserts one ai_usage row. For images cost_micros is the figure the provider reported (gpt-image bills per token and returns usage); when a response carries no usage we fall back to the pre-flight estimate and set meta.cost_estimated, which the usage log shows as "est.". Failures are not recorded, so a failed generation costs no credits.

Not yet metered: blog cover images (BlogService::generateCover) are platform spend on the platform's own key with no tenant to bill, so they are audited but not in ai_usage.

Code map

  • app/Providers/AI/AIProvider.php — interface. AnthropicProvider (raw Messages API via App\Core\Http: system, adaptive thinking, tools + runTools() tool-result loop, output_config.format JSON schema with prompt fallback, refusal handling). MockAIProvider for keyless dev/test.
  • app/Providers/AI/prompts/*.vN.md — versioned templates loaded by PromptLoader with {{var}} substitution. Bump the version suffix instead of editing a template in place.
  • app/Providers/Image/ — ImageProvider, MockImageProvider, OpenAIImageProvider, ImageCapabilities, ImageCost, Presets (ig_post, ig_story, facebook, tiktok, yt_thumb, discord_banner, twitch_panel, kick, x_header), Enhancements.
  • app/Modules/AI/Services/AIService.php — bound as $app->make('ai'). Methods: complete, structured, generateContent, adaptPost(tenantId, body, providers), contentPlan, summarizeCreator, serverSetup, graphicsBrief, renderGraphic, enhancePhoto, copilot, plus the metering pair reserveImage / recordImage and credits / monthlyUsed / assertWithinLimit.
  • app/Modules/AI/Services/CopilotTools.php — tool definitions and executors (all reads via TenantScope; writes are drafts and audited).

Routes (auth + tenant + role:staff; POSTs throttled throttle:ai)

RoutePurpose
GET/POST /dashboard/copilot, POST /dashboard/copilot/resetChat copilot; conversation in session per tenant (last 20 turns). JSON in/out.
GET/POST /dashboard/ai/content, POST /dashboard/ai/content/sendContent studio (captions, hashtags, titles, descriptions, CTAs, thumbnail text, short-form). ?plan=1 renders a 7-day plan. "→ Publisher" creates a draft post.
GET/POST /dashboard/ai/graphics, GET /dashboard/ai/galleryPreset-size graphics saved to storage + media_assets.
GET/POST /dashboard/ai/photosPhoto studio: enhance an upload or a library image. Result is always a new asset.
GET /dashboard/onboarding-wizard, POST /dashboard/onboarding-wizard/{1-5}Creator type → accounts → AI summary (Apply buttons) → community/server → "Launch your community" campaign drafts.
GET/POST /dashboard/ai/server-setup, POST /dashboard/ai/server-setup/applyPlain-language description → launch kit (VIP product inactive, posts draft, automation disabled, graphic, FAQ/rules proposals).
GET /dashboard/ai/usageMetering.

Copilot tools

get_overview, list_products, analyze_sales (read-only) · create_draft_post, create_draft_product, generate_graphic, create_automation_draft (draft-only, staff role required, audited as ai.*). The system prompt (copilot.system.v1.md) instructs the model to refuse publish/price/refund requests and point at the dashboard.

Adding a real image provider

Implement four things, then return the provider from AIService::imageProvider() based on config('providers.image.driver') and add the credentials to .env.example:

  • generate(prompt, width, height, opts) and enhance(source, prompt, opts) — both return ['bytes','mime','width','height','model','cost_micros','cost_estimated']. cost_micros should be what the provider says it billed; set cost_estimated when it did not say.
  • capabilities() → ImageCapabilities: the model name, whether it can generate and edit, whether an alpha channel is possible, how many reference images an edit takes, prompt and source-size ceilings, the quality names and native render sizes. The dashboard gates options on this — Enhancements::availableFor() hides the cut-out mode from a provider with no transparency — so an honest false is better than an optimistic true.
  • estimate(width, height, opts) → ImageCost, worked out offline. It gates the plan limit before the call, so it must not touch the network, and with an unknown output box (0, 0) it must assume the provider's dearer size rather than its cheaper one.
  • canEnhance() stays for the simple question; capabilities() carries the detailed answer.

A per-model cost table belongs in the provider, next to the request it prices, with its source in a comment — see OpenAIImageProvider::RATES (official token rates) and ESTIMATE_1K (anchors, documented as approximations). Tests use Tests\FakeImageProvider, whose costMicros / actualCostMicros / supportsTransparency knobs stand in for a paid provider without a key.

ArtCraft (getartcraft.com) was evaluated and cannot be one. It is an open-source Rust GUI desktop app
(storytold/artcraft) with no REST API, no CLI and no headless or
self-hostable server mode — a client that aggregates other vendors' models (Veo, Kling, Seedance, Sora,
Midjourney), in the same category as Photoshop. It remains a good hand-tool for making overlay and brand
assets to upload; it is not integrable. CLAUDE.md rule 8.

Images

App\Providers\Image\OpenAIImageProvider calls POST /v1/images/generations at the nearest supported size (1536×1024, 1024×1536 or 1024×1024) and resizes/centre-crops with GD to the exact preset size (Presets), so an X header (1500×500) or a Twitch panel (320×160) comes back at the requested pixels. Prompts get a "no readable text" instruction unless allow_text is set, because image models garble words. Failures throw with OpenAI's message; nothing is faked. Used by the creator graphics generator (metered per workspace in ai_usage, priced from the usage the API returns), blog covers, and one-off site imagery (assets/img/site, assets/img/guide/photos, generated 2026-09-26).

Transparency is a request field, not a prompt. opts['transparent'] sends background: transparent and forces PNG output. Asking only in the prompt was measured at 0% transparent; the field gives ~50%. This matters because a broadcast overlay without a real alpha channel is an opaque rectangle on the stream. Http::$fake exists so a test can assert on the request body — a field we never send is invisible to a test that only checks the response.

Photo studio (editing, not generating)

POST /v1/images/edits (ImageProvider::enhance), where the source image is the subject and the prompt only says what to change. Multipart is built by hand in OpenAIImageProvider::multipart() because App\Core\Http speaks json/form/raw and this endpoint needs a file part.

  • Modes, not free text (Providers\Image\Enhancements): enhance, restore, cutout (transparent), relight, upscale. Each carries its instruction and what it must not change — an edit model will redraw a face if nothing tells it the subject is the point.
  • Output size follows the source, not the caller, so an edit never silently re-crops; width/height then fit the result to an exact box.
  • The source is never overwritten. An edit is not reversible, so the result is always a new media_assets row with meta.source_asset_id. Metered as ai_usage.feature = 'photo'.
  • Priced from the source, not the output box. An edit is billed for the size the model renders at, which follows the source aspect ratio — and the output box is usually not set at all — so enhancePhoto() reads the photo's dimensions and reserves against those.
  • Inputs come from Uploads::imageBytes() (sniffed, GD re-encoded, nothing stored until the edit returns) or from the library via StorageProvider::get() — tenant-scoped, so another workspace's asset id is simply not found.
  • Measured: a 320×213 quality-18 JPEG returned 1536×1024 with real detail recovered and the composition, subject and pose intact (~19 s, 696 tokens). The cut-out returned 47% genuine alpha.

MockImageProvider::enhance does real GD work (sharpen, contrast, corner keying) rather than returning its input, so an unconfigured install and the suite both get an honest answer.