Merge branch 'main' into feat/tool-trace-v2

This commit is contained in:
molecule-ai[bot] 2026-04-23 02:09:17 +00:00 committed by GitHub
commit 16b2e5da29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 886 additions and 2433 deletions

View File

@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useRef, useCallback, useId } from "react";
import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { isSaaSTenant } from "@/lib/tenant";
interface WorkspaceOption {
id: string;
@ -14,32 +15,39 @@ interface HermesProvider {
id: string;
label: string;
envVar: string;
defaultModel: string;
models: string[];
}
// All providers supported by Hermes runtime via providers.resolve_provider()
// All providers supported by Hermes runtime via providers.resolve_provider().
// `defaultModel` is the slug injected into the workspace provision request
// when the user picks this provider — template-hermes's derive-provider.sh
// maps the prefix back to the provider name at install time, so this is
// the canonical handshake. `models` are additional suggestions surfaced in
// the datalist so the user can pick a different size without typing the
// whole slug.
export const HERMES_PROVIDERS: HermesProvider[] = [
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" },
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY" },
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY" },
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY" },
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY" },
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY" },
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" },
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" },
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY" },
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" },
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY" },
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY" },
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY" },
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY", defaultModel: "anthropic/claude-sonnet-4-5", models: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5"] },
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "openai/gpt-4o", models: ["openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini"] },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/auto", models: ["openrouter/auto", "openrouter/anthropic/claude-sonnet-4", "openrouter/meta-llama/llama-3.3-70b"] },
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY", defaultModel: "xai/grok-4", models: ["xai/grok-4", "xai/grok-4-mini"] },
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY", defaultModel: "gemini/gemini-2.5-pro", models: ["gemini/gemini-2.5-pro", "gemini/gemini-2.5-flash"] },
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY", defaultModel: "alibaba/qwen3-max", models: ["alibaba/qwen3-max", "alibaba/qwen3-coder"] },
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY", defaultModel: "zai/glm-4.6", models: ["zai/glm-4.6", "zai/glm-4.5-air"] },
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY", defaultModel: "kimi-coding/kimi-k2", models: ["kimi-coding/kimi-k2", "kimi-coding/kimi-k1.5"] },
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M2.7", models: ["minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M1"] },
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek/deepseek-chat", models: ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"] },
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "openrouter/groq/llama-3.3-70b", models: ["openrouter/groq/llama-3.3-70b"] },
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY", defaultModel: "openrouter/mistralai/mistral-large", models: ["openrouter/mistralai/mistral-large"] },
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY", defaultModel: "nousresearch/Hermes-3-Llama-3.1-405B", models: ["nousresearch/Hermes-3-Llama-3.1-405B", "nousresearch/Hermes-4-14B"] },
];
export function CreateWorkspaceButton() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState("");
const [tier, setTier] = useState(1);
const [template, setTemplate] = useState("");
const [parentId, setParentId] = useState("");
const [budgetLimit, setBudgetLimit] = useState("");
@ -50,14 +58,42 @@ export function CreateWorkspaceButton() {
// Hermes-specific state
const [hermesProvider, setHermesProvider] = useState("anthropic");
const [hermesApiKey, setHermesApiKey] = useState("");
// Model slug is sent to CP as `model` and plumbed to the workspace EC2
// as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh
// reads the prefix (`minimax/…`, `anthropic/…`) to set
// HERMES_INFERENCE_PROVIDER at install time. Missing model → provider
// falls back to "auto" and hermes picks its compiled-in default
// (Anthropic), which 401s if the user's key is for a different
// provider. Hence: require model when template=hermes.
const [hermesModel, setHermesModel] = useState("");
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
// lock to T4 — the full-host access tier, which maps to t3.large at the
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
// sandbox distinction is a real choice there; T4 is available too for
// operators who want the full-host tier.
//
// SSR-safe via isSaaSTenant() contract (returns false on server); first
// client render may flip the picker — acceptable one-frame reflow.
const isSaaS = useMemo(() => isSaaSTenant(), []);
const TIERS = useMemo(
() =>
isSaaS
? [{ value: 4, label: "T4", desc: "Full Access" }]
: [
{ value: 1, label: "T1", desc: "Sandboxed" },
{ value: 2, label: "T2", desc: "Standard" },
{ value: 3, label: "T3", desc: "Privileged" },
{ value: 4, label: "T4", desc: "Full Access" },
],
[isSaaS],
);
const defaultTier = isSaaS ? 4 : 1;
const [tier, setTier] = useState(defaultTier);
// Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
const TIERS = [
{ value: 1, label: "T1", desc: "Sandboxed" },
{ value: 2, label: "T2", desc: "Standard" },
{ value: 3, label: "T3", desc: "Full Access" },
];
const handleRadioKeyDown = useCallback(
(e: React.KeyboardEvent, currentIndex: number) => {
@ -80,22 +116,42 @@ export function CreateWorkspaceButton() {
const isHermes = template.trim().toLowerCase() === "hermes";
// Auto-fill hermesModel with the provider's defaultModel whenever the
// provider changes, but only if the user hasn't already typed their own
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
useEffect(() => {
if (!isHermes) return;
const p = HERMES_PROVIDERS.find((x) => x.id === hermesProvider);
if (!p) return;
// Replace model only if current value matches another provider's
// default (user hasn't customized it) OR is empty.
const isUntouched =
hermesModel === "" ||
HERMES_PROVIDERS.some((x) => x.defaultModel === hermesModel);
if (isUntouched) setHermesModel(p.defaultModel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hermesProvider, isHermes]);
// Reset form and load workspaces whenever dialog opens
useEffect(() => {
if (!open) return;
setName("");
setRole("");
setTier(1);
setTier(defaultTier);
setTemplate("");
setParentId("");
setBudgetLimit("");
setError(null);
setHermesProvider("anthropic");
setHermesApiKey("");
setHermesModel("");
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
.catch(() => {});
// defaultTier is stable for the session (derived from window.location),
// safe to omit from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const handleCreate = async () => {
@ -107,6 +163,10 @@ export function CreateWorkspaceButton() {
setError("API key is required for Hermes workspaces");
return;
}
if (isHermes && !hermesModel.trim()) {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
setCreating(true);
setError(null);
@ -128,7 +188,10 @@ export function CreateWorkspaceButton() {
budget_limit: parsedBudget,
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
...(isHermes && provider
? { secrets: { [provider.envVar]: hermesApiKey.trim() } }
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
model: hermesModel.trim(),
}
: {}),
});
setOpen(false);
@ -209,10 +272,10 @@ export function CreateWorkspaceButton() {
<div
role="radiogroup"
aria-label="Workspace tier"
className="grid grid-cols-3 gap-1.5"
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
>
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
Tier
<div className={`text-[11px] text-zinc-400 mb-1 ${isSaaS ? "" : "col-span-4"}`}>
Tier{isSaaS ? " — dedicated VM" : ""}
</div>
{TIERS.map((t, idx) => (
<button
@ -317,6 +380,39 @@ export function CreateWorkspaceButton() {
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
</div>
<div>
<label
htmlFor="hermes-model-input"
className="text-[11px] text-zinc-400 block mb-1"
>
Model{" "}
<span aria-hidden="true" className="text-red-400">
*
</span>
<span className="sr-only"> (required)</span>
</label>
<input
id="hermes-model-input"
type="text"
value={hermesModel}
onChange={(e) => setHermesModel(e.target.value)}
placeholder="e.g. minimax/MiniMax-M2.7"
aria-label="Hermes model slug"
autoComplete="off"
spellCheck={false}
list="hermes-model-suggestions"
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
/>
<datalist id="hermes-model-suggestions">
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
(m) => <option key={m} value={m} />,
)}
</datalist>
<p className="text-[10px] text-zinc-500 mt-1">
Slug determines which provider hermes routes to at install time.
</p>
</div>
</div>
)}

View File

@ -77,7 +77,9 @@ describe("CreateWorkspaceDialog — accessibility", () => {
it("tier buttons have role=radio and aria-checked reflects selection", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
expect(radios.length).toBe(3);
// Non-SaaS build (jsdom hostname is localhost) shows all four tiers:
// T1 Sandboxed, T2 Standard, T3 Privileged, T4 Full Access.
expect(radios.length).toBe(4);
// T1 is default selection
const t1 = radios.find((r) => r.textContent?.includes("T1"));
const t2 = radios.find((r) => r.textContent?.includes("T2"));
@ -98,10 +100,12 @@ describe("CreateWorkspaceDialog — accessibility", () => {
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t2 = radios.find((r) => r.textContent?.includes("T2"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
// T1 is default selected
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
// T1 is default selected (non-SaaS test env; SaaS would default to T4)
expect(t1.getAttribute("tabindex")).toBe("0");
expect(t2.getAttribute("tabindex")).toBe("-1");
expect(t3.getAttribute("tabindex")).toBe("-1");
expect(t4.getAttribute("tabindex")).toBe("-1");
});
it("ArrowDown moves selection from T1 to T2", async () => {
@ -127,15 +131,15 @@ describe("CreateWorkspaceDialog — accessibility", () => {
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
});
it("ArrowDown wraps from T3 back to T1", async () => {
it("ArrowDown wraps from T4 back to T1", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
fireEvent.click(t3); // select T3 first
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
t3.focus();
fireEvent.keyDown(t3, { key: "ArrowDown" });
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
fireEvent.click(t4); // select T4 (last) first
await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true"));
t4.focus();
fireEvent.keyDown(t4, { key: "ArrowDown" });
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
});
@ -151,14 +155,14 @@ describe("CreateWorkspaceDialog — accessibility", () => {
await waitFor(() => expect(t1.getAttribute("aria-checked")).toBe("true"));
});
it("ArrowLeft wraps from T1 back to T3", async () => {
it("ArrowLeft wraps from T1 back to T4", async () => {
await openDialog();
const radios = screen.getAllByRole("radio");
const t1 = radios.find((r) => r.textContent?.includes("T1"))!;
const t3 = radios.find((r) => r.textContent?.includes("T3"))!;
const t4 = radios.find((r) => r.textContent?.includes("T4"))!;
t1.focus();
fireEvent.keyDown(t1, { key: "ArrowLeft" });
await waitFor(() => expect(t3.getAttribute("aria-checked")).toBe("true"));
await waitFor(() => expect(t4.getAttribute("aria-checked")).toBe("true"));
});
});

View File

@ -1,6 +1,5 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { SecretGroup } from '@/types/secrets';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useSecretsStore } from '@/stores/secrets-store';
import { KeyValueField } from '@/components/ui/KeyValueField';
import { ValidationHint } from '@/components/ui/ValidationHint';
@ -10,7 +9,7 @@ import {
isValidKeyName,
inferGroup,
} from '@/lib/validation/secret-formats';
import { SERVICES, SERVICE_GROUP_ORDER, getDefaultKeyName } from '@/lib/services';
import { SERVICES, KEY_NAME_SUGGESTIONS } from '@/lib/services';
const VALIDATION_DEBOUNCE_MS = 400;
@ -23,9 +22,21 @@ interface AddKeyFormProps {
/**
* Inline-expanding form for adding a new API key.
*
* Flow (from spec §4.2):
* Form Open select service key name auto-fills type value
* optional Test Connection Save
* Design note (2026-04-22): the form used to open with a Service
* dropdown (GitHub / Anthropic / OpenRouter / Other) gating what to
* do next. That added friction the storage layer only cares about
* (key_name, value), and the provider can always be inferred from the
* key name itself. We removed the dropdown and rely on:
*
* - A datalist of common key-name suggestions so autocomplete
* replaces "pick a provider then the name auto-fills"
* - inferGroup(keyName) to classify the secret for validation +
* list-view grouping + test-connection routing, derived at render
* time from what the user actually typed
*
* Result: fewer fields, provider-agnostic by design, no UI code change
* needed to onboard a new provider (MiniMax, DeepSeek, etc. just work
* as soon as you type their canonical env var name).
*/
export function AddKeyForm({
workspaceId,
@ -34,8 +45,7 @@ export function AddKeyForm({
}: AddKeyFormProps) {
const createSecret = useSecretsStore((s) => s.createSecret);
const [selectedGroup, setSelectedGroup] = useState<SecretGroup>('github');
const [keyName, setKeyName] = useState(getDefaultKeyName('github'));
const [keyName, setKeyName] = useState('');
const [value, setValue] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [keyNameError, setKeyNameError] = useState<string | null>(null);
@ -43,23 +53,13 @@ export function AddKeyForm({
const [saveError, setSaveError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const service = SERVICES[selectedGroup];
// Auto-fill key name when service changes
const handleServiceChange = useCallback(
(group: SecretGroup) => {
setSelectedGroup(group);
const defaultName = getDefaultKeyName(group);
if (defaultName) {
setKeyName(defaultName);
}
// Reset validation
setValidationError(null);
setKeyNameError(null);
setSaveError(null);
},
[],
);
// Group is derived, not selected. Falls back to 'custom' for any
// key name that doesn't match a known provider pattern — validation
// and test-connection still work, just without provider-specific
// format hints.
const inferredGroup = useMemo(() => inferGroup(keyName || ''), [keyName]);
const service = SERVICES[inferredGroup];
// Validate key name
useEffect(() => {
@ -78,7 +78,7 @@ export function AddKeyForm({
setKeyNameError(null);
}, [keyName, existingNames]);
// Debounced value validation
// Debounced value validation against the inferred provider's format.
useEffect(() => {
if (!value) {
setValidationError(null);
@ -86,18 +86,17 @@ export function AddKeyForm({
}
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setValidationError(validateSecretValue(value, selectedGroup));
setValidationError(validateSecretValue(value, inferredGroup));
}, VALIDATION_DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [value, selectedGroup]);
}, [value, inferredGroup]);
const handleSave = useCallback(async () => {
// Final validation pass
if (!isValidKeyName(keyName)) {
setKeyNameError('Key name must be UPPER_SNAKE_CASE');
return;
}
const valErr = validateSecretValue(value, selectedGroup);
const valErr = validateSecretValue(value, inferredGroup);
if (valErr) {
setValidationError(valErr);
return;
@ -114,32 +113,21 @@ export function AddKeyForm({
} finally {
setIsSaving(false);
}
}, [keyName, value, selectedGroup, createSecret, workspaceId]);
}, [keyName, value, inferredGroup, createSecret, workspaceId]);
const canSave = keyName && value && !keyNameError && !validationError && !isSaving;
// Show the provider-specific docs hint only when the key name
// matches a known provider. For 'custom' (unknown key name) we stay
// quiet — no false-structure prompt.
const showProviderHint = inferredGroup !== 'custom' && service.docsUrl;
return (
<div className="add-key-form">
<div className="add-key-form__header">Add New Key</div>
{/* Service selector */}
<label className="add-key-form__label">
Service
<select
value={selectedGroup}
onChange={(e) => handleServiceChange(e.target.value as SecretGroup)}
disabled={isSaving}
className="add-key-form__select"
>
{SERVICE_GROUP_ORDER.map((group) => (
<option key={group} value={group}>
{SERVICES[group].label}
</option>
))}
</select>
</label>
{/* Key name */}
{/* Key name autocomplete replaces the old Service dropdown.
inferGroup(keyName) derives classification at render time. */}
<label className="add-key-form__label">
Key name
<input
@ -147,14 +135,32 @@ export function AddKeyForm({
value={keyName}
onChange={(e) => setKeyName(e.target.value.toUpperCase())}
disabled={isSaving}
placeholder="MY_API_KEY"
placeholder="e.g. ANTHROPIC_API_KEY, MINIMAX_API_KEY, GITHUB_TOKEN"
className="add-key-form__input"
autoComplete="off"
spellCheck={false}
list="add-key-name-suggestions"
/>
</label>
{keyNameError && (
<ValidationHint error={keyNameError} />
<datalist id="add-key-name-suggestions">
{KEY_NAME_SUGGESTIONS.map((name) => (
<option key={name} value={name} />
))}
</datalist>
{keyNameError && <ValidationHint error={keyNameError} />}
{showProviderHint && (
<div className="add-key-form__hint" data-testid="provider-hint">
<span className="add-key-form__hint-label">{service.label}</span>
{' — '}
<a
href={service.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="add-key-form__hint-link"
>
get a key
</a>
</div>
)}
{/* Key value */}
@ -172,22 +178,21 @@ export function AddKeyForm({
showValid={!validationError && value.length > 0}
/>
{/* Test connection (only for supported services) */}
{/* Test connection (only when the inferred group supports it AND
value looks format-valid). */}
{service.testSupported && value && !validationError && (
<TestConnectionButton
provider={selectedGroup}
provider={inferredGroup}
secretValue={value}
/>
)}
{/* Save error */}
{saveError && (
<div className="add-key-form__error" role="alert">
{saveError}
</div>
)}
{/* Actions */}
<div className="add-key-form__actions">
<button
type="button"

View File

@ -1,10 +1,18 @@
import type { ServiceConfig, SecretGroup } from '@/types/secrets';
/**
* Static service registry. Each known provider maps to its display
* properties, expected key names, and whether test-connection is supported.
* Static service registry used for LIST-view rendering: the
* per-group icon, the "get a key" docs link shown as a hint once
* the user types a matching key name, and the test-connection
* routing for the 3 providers with backend test endpoints.
*
* Keys not matching any known service fall into the "custom" catch-all.
*
* Note (2026-04-22): the Add-Key form no longer uses this as a
* user-facing dropdown. It reads keyNames[0] via getDefaultKeyName
* still referenced by a couple of legacy call sites and the
* Add form's autocomplete source lives in KEY_NAME_SUGGESTIONS
* below. SERVICES is purely for post-save display + test routing.
*/
export const SERVICES: Record<SecretGroup, ServiceConfig> = {
github: {
@ -49,3 +57,43 @@ export const SERVICE_GROUP_ORDER: SecretGroup[] = [
export function getDefaultKeyName(group: SecretGroup): string {
return SERVICES[group].keyNames[0] ?? '';
}
/**
* Autocomplete suggestions for the Add-Key form's key-name input.
*
* Covers the providers hermes-agent supports natively + the common
* infra keys (GitHub, platform-side). Adding a new provider here is
* a one-line change the Add form picks it up via <datalist>, and
* classification (for validation + list grouping) comes from
* inferGroup in lib/validation/secret-formats.ts.
*
* Order: alphabetical for stable display in autocomplete popups.
*/
export const KEY_NAME_SUGGESTIONS: readonly string[] = [
'AI_GATEWAY_API_KEY',
'ANTHROPIC_API_KEY',
'ARCEEAI_API_KEY',
'COPILOT_GITHUB_TOKEN',
'DASHSCOPE_API_KEY',
'DEEPSEEK_API_KEY',
'GEMINI_API_KEY',
'GH_TOKEN',
'GITHUB_TOKEN',
'GLM_API_KEY',
'GOOGLE_API_KEY',
'HERMES_API_KEY',
'HF_TOKEN',
'KILOCODE_API_KEY',
'KIMI_API_KEY',
'KIMI_CN_API_KEY',
'MINIMAX_API_KEY',
'MINIMAX_CN_API_KEY',
'NOUS_API_KEY',
'NVIDIA_API_KEY',
'OLLAMA_API_KEY',
'OPENAI_API_KEY',
'OPENCODE_GO_API_KEY',
'OPENCODE_ZEN_API_KEY',
'OPENROUTER_API_KEY',
'XIAOMI_API_KEY',
] as const;

View File

@ -54,3 +54,18 @@ export function getTenantSlug(): string {
if (reservedSubdomains.has(slug)) return "";
return slug;
}
/**
* isSaaSTenant reports whether the canvas is running as the UI for a
* SaaS tenant (served at <slug>.moleculesai.app). Use for client-side
* UX branches that should behave differently on SaaS vs self-hosted
* e.g. the workspace tier picker hides T1/T2 sandbox tiers because every
* SaaS workspace gets its own EC2 VM (inherently T3 Full Access).
*
* SSR-safe: returns false on the server to avoid hydration drift; call
* sites should tolerate a flip from falsetrue on first client render.
*/
export function isSaaSTenant(): boolean {
if (typeof window === "undefined") return false;
return getTenantSlug() !== "";
}

View File

@ -1,65 +0,0 @@
# SEO Brief: How to Add Browser Automation to AI Agents with MCP
**Date:** 2026-04-20
**Author:** SEO Analyst → Content Marketer
**Last Updated:** 2026-04-20 (post-revision)
**Status:** ACTIONS 15 COMPLETE. Action 6 on hold pending post review.
**Campaign:** Chrome DevTools MCP SEO
---
## 1. Goal
Drive organic signups for Molecule AI by ranking for tail keywords in the AI agent + browser automation space. Secondary: demonstrate Molecule AI's MCP integration capabilities through a concrete, code-forward tutorial.
## 2. Target Keywords
- Primary: `browser automation AI agents`, `MCP browser`, `AI agent web scraping`
- Secondary: `Chrome DevTools MCP`, `AI agent browser control`, `MCP protocol tutorial`
- Long-tail: `how to add browser automation to AI agents`, `use Chrome with AI agent`, `MCP CDP integration`
## 3. Audience
Developers building AI agents in Python/JS who need web interaction capabilities (scraping, form filling, screenshot capture, automated testing). Mid-senior level. They have heard of MCP and want to see it in action.
## 4. Angle / Hook (revised per PMM)
Lead with outcome, not protocol. Better headline: *"Give Your AI Agent a Real Browser: MCP + Chrome DevTools."* MCP is the bridge; the outcome is a browser-wielding agent. Do not assume MCP literacy — define it in the first 100 words.
**Tone:** Technical but accessible. Code-first. No fluff.
## 5. SEO Requirements
- Word count: 1,5002,200 words ✅ ~1,900 words
- Headline: ✅ "Give Your AI Agent a Real Browser: MCP + Chrome DevTools" (revised)
- Meta title: ✅ "Give Your AI Agent a Real Browser: MCP + Chrome DevTools"
- Meta description: ✅ "Learn how to add browser automation to your AI agents using Chrome DevTools and the Model Context Protocol. Full Python code examples — no Puppeteer wrappers, no SaaS dependencies."
- Subheadings: H2s with target keywords where natural ✅
- Internal links: ✅ MCP server setup guide, quickstart, deploy-anywhere post, fly-machines tutorial
- External links: ✅ MCP spec (modelcontextprotocol.io), CDP docs
- CTA: ✅ GitHub + quickstart links
- Estimated publish: Pending push (token unavailable)
## 6. PMM Feedback Applied (2026-04-20)
- ✅ Outcome-first headline
- ✅ MCP defined in intro for non-MCP-literate readers
- ✅ Infrastructure comparison table (custom Playwright vs SaaS vs Molecule AI + MCP)
- ✅ "Zero-config" claim backed by 3-line workspace YAML config
- ✅ Competitive differentiation vs LangChain, CrewAI, n8n woven into use cases
- ✅ Cost comparison (per-session SaaS vs free self-hosted)
- ✅ External links to MCP + CDP official docs added
## 7. Deliverables — ALL COMPLETE
| # | Deliverable | File | Status |
|---|---|---|---|
| — | SEO Brief | `docs/marketing/briefs/2026-04-20-chrome-devtools-mcp-seo-brief.md` | ✅ |
| 1 | Blog Post | `docs/blog/2026-04-20-chrome-devtools-mcp-seo/index.md` | ✅ Revised |
| 2 | Social Copy | `docs/marketing/campaigns/chrome-devtools-mcp-seo/social-copy.md` | ✅ Draft |
| 3 | Internal Linking | — | ✅ Done |
| 4 | Sitemap Update | — | ⏸ No sitemap.xml in repo (auto-gen) |
| 5 | Analytics Blueprint | `docs/marketing/campaigns/chrome-devtools-mcp-seo/analytics-tracking.md` | ✅ |
| 6a | Outreach Target List | `docs/marketing/campaigns/chrome-devtools-mcp-seo/outreach-targets.md` | ✅ Prep done |
| 6b | Backlink Outreach | — | ⏸ **ON HOLD** — do not outreach until post live + reviewed |
## 8. Git Status
6 commits on `staging` branch, all locally committed. Push blocked — no git token.
Marketing Lead needs to push or grant token access.
## 9. Review / Approval
- PMM: ✅ Reviewed, substantive feedback applied
- Marketing Lead: ⏸ Unreachable via delegation — needs to review final post before outreach begins
- SEO Analyst: ⚠️ Owns Actions 26; Action 1 executed by Content Marketer due to Content Marketer unavailability

View File

@ -1,129 +0,0 @@
# SEO Brief: Phase 30 — Remote Workspaces / SaaS Federation
**Issue:** #1126
**Date:** 2026-04-20 (updated 2026-04-21)
**Author:** SEO Analyst
**Campaign:** Phase 30 Remote Workspaces
**Status:** BRIEF DRAFT — pending PMM positioning review
---
## 1. Context
Phase 30 ships per-workspace bearer tokens, unified fleet visibility, and remote agent registration for heterogeneous AI agent fleets spanning laptops, cloud VMs, CI/CD pipelines, on-premise servers, and SaaS integrations.
**Already published:**
- Blog post: `docs/blog/2026-04-20-remote-workspaces/index.md`
- Title: "One Canvas, Every Agent: Remote AI Agents and Fleet Visibility on Molecule AI"
- Covers: fleet visibility problem, bearer token security model, agent registration, heartbeat, org placement
**This brief:** Additional SEO content needed to support the launch and capture long-tail informational queries.
---
## 2. Target Keywords
| Keyword | Intent | Difficulty | Priority |
|---|---|---|---|
| `remote AI agent deployment` | Informational | Low | High |
| `self-hosted AI agents platform` | Informational / Commercial | Medium | High |
| `AI agent SaaS federation` | Informational | Low | Medium |
| `cross-network AI orchestration` | Informational | Low | Medium |
| `federated AI agents` | Informational | Low | Medium |
| `AI agent fleet management` | Informational / Transactional | Medium | High |
| `self-host Claude Code agents` | Informational | Low | High |
| `multi-cloud AI agent platform` | Commercial | Medium | Medium |
| `remote AI agent canvas` | Navigational | Low | Medium |
**Primary angle:** `remote AI agent deployment` + `self-hosted AI agents platform` — these capture the developer audience searching for how to deploy agents outside a single cloud/VPS.
---
## 3. Content Gap Analysis
### Already covered (blog post):
- Fleet visibility problem framing
- Bearer token security model
- Agent registration flow
- Heartbeat mechanism
- Org placement
### Missing for SEO:
| Gap | Content type | Priority | Rationale |
|---|---|---|---|
| Step-by-step: register a remote agent | Tutorial / How-to | High | High search intent, procedural |
| Self-hosted remote agents setup | Tutorial / How-to | High | Complements `self-hosted AI agents platform` kw |
| Remote agent vs Docker workspace | Comparison / FAQ | Medium | Common confusion point |
| Cross-network A2A walkthrough | Tutorial | Medium | Technical audience |
| Remote agent on fly machines | Tutorial | Medium | Specific infra angle |
---
## 4. Content Recommendation
**This is a docs play, not a landing page play.**
Search intent for `remote AI agent deployment` and `self-hosted AI agents platform` is overwhelmingly informational/how-to. Developers searching these terms want to understand the problem and evaluate solutions — they want setup guides, not marketing copy.
**Recommended content sequence:**
1. **Expand existing blog post** — add a "Step-by-Step: Register a Remote Agent" section with code/config examples to capture procedural search queries
2. **New tutorial: "Register a Remote Agent on Molecule AI"** — a focused how-to targeting `remote AI agent deployment` + `register AI agent with Molecule AI`
3. **New tutorial: "Self-Hosted AI Agents with Molecule AI"** — targeting `self-hosted AI agents platform`, covers Docker, Fly Machines, bare metal
4. **Update: `docs/agent-runtime/workspace-runtime.md`** — add remote agents section with bearer token setup
5. **Update: `docs/guides/external-agent-registration.md`** — if exists, audit for Phase 30 coverage; if not, create
---
## 5. Docs Pages to Update Post-Launch
| Page | Update needed |
|---|---|
| `docs/agent-runtime/workspace-runtime.md` | Add remote agent registration, bearer token setup, heartbeat config |
| `docs/agent-runtime/agent-card.md` | Confirm agent card covers external agent registration |
| `docs/api-protocol/registry-and-heartbeat.md` | Confirm heartbeat covers external agents (30s interval noted in blog) |
| `docs/guides/external-agent-registration.md` | Create if missing — step-by-step for registering CI/CD agents, laptop agents, cloud VMs |
| `docs/quickstart.md` | Add remote agent path alongside Docker/Fly Machines |
| `docs/index.md` | Add Remote Agents to product features list |
---
## 6. PMM Positioning Review Needed
The issue #1126 acceptance criteria specifies: "Coordinate with PMM (issue #1116) on positioning language."
**Questions for PMM:**
1. **Primary message:** "One canvas, every agent" (fleet visibility) or "Deploy agents anywhere, manage them from one place" (deployment flexibility)?
2. **Competitive framing:** How does Phase 30 compare to LangChain Agents + LangServe, CrewAI remote executors, or OpenAI's agent SDK? Any positioning lines to own?
3. **Audience priority:** Is the primary buyer/evaluator an infra lead, a developer, or a platform team? This affects keyword targeting and content tone.
4. **Pricing/availability:** Is Phase 30 live for all tiers or a specific plan? Affects CTA language.
---
## 7. Action Items
| # | Action | Owner | Status |
|---|---|---|---|
| 1 | Keyword research (this brief) | SEO Analyst | ✅ Draft done |
| 2 | PMM positioning review | PMM (issue #1116) | ⏸ Holding — PMM Slack: "Phase 30 position holding" |
| 3 | Expand blog post with step-by-step | Content Marketer | ⏸ Pending PMM |
| 4 | Draft tutorial: "Register a Remote Agent" | SEO Analyst | ✅ Done — `docs/tutorials/register-remote-agent.md`, pushed to molecule-core@main |
| 5 | Draft tutorial: "Self-Hosted AI Agents" | SEO Analyst | ✅ Done — `docs/tutorials/self-hosted-ai-agents.md`, pushed to molecule-core@main |
| 6 | Update workspace-runtime.md | DevRel | ✅ Done — remote agent registration section already on main |
| 7 | Audit/create external-agent-registration.md | DevRel | ✅ Done — already on main, full coverage |
| 8 | Update quickstart.md + docs/index.md | DevRel | ✅ Done — Remote Agent path in quickstart; docs/index.md updated with Remote Agents feature card + blog links |
---
## 8. Campaign Assets
**Blog post URL (live):** `https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-20-remote-workspaces/index.md`
**Internal links to add once tutorials are published:**
- Blog post → Remote Agent tutorial
- Quickstart → Remote Agent section
- Agent Card docs → remote registration section
- External Agent tutorial → A2A cross-network walkthrough
---
*Draft by SEO Analyst 2026-04-21 — pending PMM positioning review*

View File

@ -1,120 +0,0 @@
# Analytics Tracking Blueprint
## Chrome DevTools MCP SEO Campaign — Blog Post
**Post URL:** /blog/browser-automation-ai-agents-mcp
**Date:** 2026-04-20
**Author:** Content Marketer (executed Actions 35)
**Status:** Blueprint — needs to be applied by Marketing Lead or whoever has GA4/PostHog access
---
## GA4 Events to Configure
### Page Views
| Event | Trigger | Parameter |
|---|---|---|
| `page_view` | Automatic | `page_location`, `page_referrer` |
| `blog_view` | Blog post loaded | `post_slug`, `post_title`, `traffic_source` |
### Engagement Events
| Event | Trigger | Parameter |
|---|---|---|
| `scroll` | 75% scroll depth | `post_slug`, `percent_scrolled` |
| `time_on_page` | 30s, 60s, 120s | `post_slug`, `time_bucket` |
| `copy_code` | Code block copied | `post_slug`, `code_type` (CDP example, config, etc.) |
### CTA Clicks (apply to specific links)
| Event | Trigger | Element | GA4 Action |
|---|---|---|---|
| `cta_click` | "Get started on GitHub" link | `text: "Get started on GitHub →"` | `blog_cta_click` |
| `cta_click` | "Quickstart" link | `href: /docs/quickstart` | `blog_cta_click` |
| `cta_click` | "MCP Server Setup Guide" link | `href: /docs/guides/mcp-server-setup` | `blog_cta_click` |
| `cta_click` | GitHub star / repo link | `href: github.com/Molecule-AI/molecule-core` | `github_cta_click` |
**GA4 conversion setup for CTAs:**
- Create a **Blog CTA Click** custom event-based conversion
- Trigger: `event_name = "cta_click"`
- Filter: `post_slug = "browser-automation-ai-agents-mcp"`
---
## PostHog Events to Configure
PostHog has richer user-level tracking. If PostHog is installed on the docs site:
| Event | Trigger | Properties |
|---|---|---|
| `pageview` | Blog loaded | `slug`, `title`, `referrer`, `utm_source`, `utm_medium`, `utm_campaign` |
| `blog_scrolled_75` | 75% scroll | `slug`, `title` |
| `blog_code_copied` | Clipboard write | `slug`, `code_language`, `code_block_type` |
| `blog_cta_clicked` | CTA link clicked | `slug`, `cta_label`, `cta_url`, `destination` |
### PostHog Funnels to Build
**Funnel 1 — Trial conversion**
```
Blog page view → MCP Server Setup Guide click → Quickstart click → GitHub CTA click
```
**Funnel 2 — Engagement depth**
```
Blog page view → 75% scroll → Code copy event → CTA click
```
**Funnel 3 — Resource consumption**
```
Blog page view → Internal link click (deploy-anywhere or fly-machines) → GitHub CTA
```
### PostHog Feature Flags (if relevant)
- If A/B testing CTA copy or placement, use `feature_flag_called("blog_cta_variant")`
- Track per-variant click-through rate
---
## UTM Parameters for Campaign Tracking
Apply these to all outbound links in the blog post and social posts driving traffic to it:
| Source | Medium | Campaign | Content |
|---|---|---|---|
| `linkedin` | `social` | `chrome-devtools-mcp-seo` | `post-1`, `post-2`, `post-3` |
| `twitter` | `social` | `chrome-devtools-mcp-seo` | `thread-p1`, `thread-p2` |
| `direct` | `organic-search` | `chrome-devtools-mcp-seo` | (blank) |
| `newsletter` | `email` | `chrome-devtools-mcp-seo` | (blank) |
---
## SEO Ranking Signals to Monitor
| Signal | Tool | Check frequency |
|---|---|---|
| Keyword ranking: "browser automation AI agents" | Google Search Console | Weekly |
| Keyword ranking: "MCP browser" | GSC | Weekly |
| Impressions + CTR for blog post URL | GSC | Weekly |
| Core Web Vitals (LCP, CLS, INP) for post page | PageSpeed Insights / GSC | At publish + 30 days |
| Backlinks acquired | Ahrefs / Moz | Monthly |
---
## Traffic Baseline
Capture baseline metrics **at time of publish** so 30/60/90-day deltas are meaningful:
- GSC: impressions, clicks, CTR for target keywords
- GA4: blog sessions, scroll depth distribution, CTA click rate
- GitHub: referrer traffic to molecule-core repo
---
## Action Owners
| Task | Owner |
|---|---|
| Apply GA4 events | Marketing Lead or DevRel |
| Apply PostHog events | DevRel |
| Build PostHog funnels | Marketing Lead |
| Monitor GSC rankings weekly | SEO Analyst (your reporting cycle) |
| Backlink outreach | SEO Analyst (Actions 6, pending post review) |
---
*Last updated: 2026-04-20 by Content Marketer*

View File

@ -1,102 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 340">
<defs>
<style>
.bg { fill: #0d1117; }
.card { fill: #161b22; stroke: #30363d; stroke-width: 1.5; rx: 12; }
.header-bg { fill: #1c2128; }
.row-alt { fill: #1c2128; }
.header-text { font-family: system-ui, sans-serif; fill: #f0f6fc; font-size: 12px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; }
.cell-text { font-family: 'JetBrains Mono', 'Fira Code', monospace; fill: #c9d1d9; font-size: 12px; }
.cell-label { font-family: system-ui, sans-serif; fill: #c9d1d9; font-size: 12px; }
.cell-sm { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 11px; }
.badge { rx: 4; }
.badge-green { fill: #238636; }
.badge-yellow { fill: #9e6a03; }
.badge-blue { fill: #1f6feb; }
.badge-red { fill: #da3633; }
.title { font-family: system-ui, sans-serif; fill: #f0f6fc; font-size: 18px; font-weight: 700; }
.subtitle { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 12px; }
.highlight { font-family: system-ui, sans-serif; fill: #58a6ff; font-size: 12px; font-weight: 700; }
.col-header { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
</style>
</defs>
<rect width="800" height="340" class="bg"/>
<!-- Title -->
<text x="400" y="30" text-anchor="middle" class="title">Browser Automation for AI Agents — 3 Approaches</text>
<text x="400" y="48" text-anchor="middle" class="subtitle">Setup effort, session management, and cost compared</text>
<!-- Table -->
<rect x="40" y="65" width="720" height="250" class="card"/>
<!-- Header row -->
<rect x="40" y="65" width="720" height="36" class="header-bg"/>
<text x="60" y="86" class="header-text">Approach</text>
<text x="280" y="86" class="col-header">Setup</text>
<text x="420" y="86" class="col-header">Session Mgmt</text>
<text x="560" y="86" class="col-header">Cost</text>
<text x="700" y="86" class="col-header">For</text>
<!-- Divider -->
<line x1="40" y1="101" x2="760" y2="101" stroke="#30363d" stroke-width="1"/>
<!-- Row 1: Custom Playwright -->
<rect x="40" y="101" width="720" height="58" class="row-alt"/>
<text x="60" y="125" class="cell-label" font-weight="600">Custom Puppeteer / Playwright</text>
<text x="60" y="141" class="cell-sm">DIY Python wrapper</text>
<text x="280" y="128" class="highlight">High</text>
<text x="280" y="143" class="cell-sm">Write + maintain wrapper</text>
<text x="420" y="128" class="highlight">DIY</text>
<text x="420" y="143" class="cell-sm">You handle timeouts, retries</text>
<rect x="555" y="115" width="80" height="20" class="badge badge-green"/>
<text x="595" y="129" text-anchor="middle" fill="white" font-family="system-ui" font-size="11" font-weight="600">Free</text>
<text x="555" y="155" class="cell-sm">your infra</text>
<text x="700" y="128" class="cell-sm">Self-hosters</text>
<!-- Row 2: SaaS Browser API -->
<rect x="40" y="159" width="720" height="58"/>
<text x="60" y="183" class="cell-label" font-weight="600">SaaS Browser API</text>
<text x="60" y="199" class="cell-sm">Browserbase, Steel, Scale</text>
<text x="280" y="186" class="highlight">Low</text>
<text x="280" y="201" class="cell-sm">Managed by vendor</text>
<text x="420" y="186" class="highlight">Managed</text>
<text x="420" y="201" class="cell-sm">Vendor handles sessions</text>
<rect x="555" y="173" width="80" height="20" class="badge badge-yellow"/>
<text x="595" y="187" text-anchor="middle" fill="white" font-family="system-ui" font-size="11" font-weight="600">Per-session</text>
<text x="555" y="213" class="cell-sm">varies by vendor</text>
<text x="700" y="186" class="cell-sm">Quick prototypes</text>
<!-- Row 3: Molecule AI + MCP — highlighted -->
<rect x="40" y="217" width="720" height="58" fill="#1f2d3d" rx="0"/>
<rect x="40" y="217" width="720" height="58" fill="#1c2a3a"/>
<rect x="40" y="217" width="3" height="58" fill="#58a6ff"/>
<text x="60" y="241" fill="#58a6ff" font-family="system-ui" font-size="13" font-weight="700">Molecule AI + MCP ✓</text>
<text x="60" y="257" class="cell-sm">Built into Molecule AI workspace</text>
<text x="280" y="242" fill="#3fb950" font-family="system-ui" font-size="13" font-weight="700">Low</text>
<text x="280" y="257" class="cell-sm">3-line YAML config</text>
<text x="420" y="242" fill="#3fb950" font-family="system-ui" font-size="13" font-weight="700">Agent-native</text>
<text x="420" y="257" class="cell-sm">persistent session, no human wiring</text>
<rect x="555" y="229" width="80" height="20" class="badge badge-blue"/>
<text x="595" y="243" text-anchor="middle" fill="white" font-family="system-ui" font-size="11" font-weight="600">Free*</text>
<text x="555" y="269" class="cell-sm">self-hosted / standard tier</text>
<text x="700" y="242" class="cell-sm">Production AI agents</text>
<!-- Footer note -->
<text x="400" y="298" text-anchor="middle" class="cell-sm">* Free when self-hosted. SaaS pricing varies by Molecule AI plan. MCP is open source.</text>
<!-- Molecule AI label -->
<text x="760" y="82" text-anchor="end" fill="#58a6ff" font-family="system-ui" font-size="10" font-weight="600">RECOMMENDED</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1,100 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420">
<defs>
<style>
.bg { fill: #0d1117; }
.box { fill: #161b22; stroke: #30363d; stroke-width: 1.5; rx: 8; }
.label { font-family: 'JetBrains Mono', 'Fira Code', monospace; fill: #c9d1d9; font-size: 13px; }
.label-sm { font-family: 'JetBrains Mono', 'Fira Code', monospace; fill: #8b949e; font-size: 11px; }
.title { font-family: system-ui, sans-serif; fill: #f0f6fc; font-size: 15px; font-weight: 600; }
.subtitle { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 11px; }
.arrow { stroke: #58a6ff; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
.arrow-label { font-family: system-ui, sans-serif; fill: #58a6ff; font-size: 11px; }
.tool-box { fill: #1c2128; stroke: #388bfd; stroke-width: 1.5; rx: 6; }
.mcp-badge { fill: #1f6feb; rx: 4; }
.cdp-badge { fill: #238636; rx: 4; }
</style>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#58a6ff"/>
</marker>
</defs>
<!-- Background -->
<rect width="800" height="420" class="bg"/>
<!-- Title -->
<text x="400" y="28" text-anchor="middle" class="title">AI Agent → MCP → CDP → Chrome</text>
<text x="400" y="44" text-anchor="middle" class="subtitle">Browser automation via the Model Context Protocol</text>
<!-- AI Agent box -->
<rect x="20" y="75" width="180" height="90" class="box"/>
<text x="110" y="103" text-anchor="middle" class="title" style="font-size:13px">AI Agent</text>
<text x="110" y="120" text-anchor="middle" class="label-sm">"Extract pricing</text>
<text x="110" y="134" text-anchor="middle" class="label-sm">from competitor.com"</text>
<text x="110" y="152" text-anchor="middle" class="subtitle">reasoning + planning</text>
<!-- Arrow 1: Agent → MCP Server -->
<line x1="200" y1="120" x2="290" y2="120" class="arrow"/>
<text x="245" y="112" text-anchor="middle" class="arrow-label">MCP invoke</text>
<text x="245" y="128" text-anchor="middle" class="label-sm">browser_navigate</text>
<!-- MCP Server box -->
<rect x="290" y="75" width="200" height="90" class="box"/>
<rect x="300" y="82" width="44" height="18" class="mcp-badge"/>
<text x="322" y="95" text-anchor="middle" fill="white" font-family="system-ui" font-size="10" font-weight="700">MCP</text>
<text x="390" y="95" text-anchor="middle" class="title" style="font-size:13px">MCP Server</text>
<text x="390" y="116" text-anchor="middle" class="label-sm">tool schema validation</text>
<text x="390" y="130" text-anchor="middle" class="label-sm">session management</text>
<text x="390" y="144" text-anchor="middle" class="label-sm">WebSocket lifecycle</text>
<text x="390" y="158" text-anchor="middle" class="label-sm">CDP command dispatch</text>
<!-- Arrow 2: MCP Server → CDP -->
<line x1="490" y1="120" x2="580" y2="120" class="arrow"/>
<text x="535" y="112" text-anchor="middle" class="arrow-label">CDP command</text>
<text x="535" y="128" text-anchor="middle" class="label-sm">Page.navigate</text>
<!-- CDP Engine box -->
<rect x="580" y="75" width="200" height="90" class="box"/>
<rect x="590" y="82" width="40" height="18" class="cdp-badge"/>
<text x="610" y="95" text-anchor="middle" fill="white" font-family="system-ui" font-size="10" font-weight="700">CDP</text>
<text x="680" y="95" text-anchor="middle" class="title" style="font-size:13px">Chrome DevTools</text>
<text x="680" y="116" text-anchor="middle" class="label-sm">WebSocket JSON-RPC 2.0</text>
<text x="680" y="130" text-anchor="middle" class="label-sm">Page / DOM / Runtime</text>
<text x="680" y="144" text-anchor="middle" class="label-sm">Input / Network domains</text>
<!-- Chrome Browser box -->
<rect x="240" y="220" width="320" height="80" class="tool-box"/>
<text x="400" y="248" text-anchor="middle" class="title" style="font-size:14px">🐙 Headless Chrome</text>
<text x="400" y="268" text-anchor="middle" class="label-sm">remote debugging port 9222</text>
<text x="400" y="283" text-anchor="middle" class="label-sm">persistent session: cookies, localStorage</text>
<!-- Vertical arrows: CDP → Chrome -->
<line x1="630" y1="165" x2="630" y2="210" class="arrow"/>
<line x1="630" y1="210" x2="560" y2="220" class="arrow" style="stroke-dasharray:4,2"/>
<line x1="400" y1="165" x2="400" y2="210" class="arrow"/>
<line x1="400" y1="210" x2="470" y2="220" class="arrow" style="stroke-dasharray:4,2"/>
<!-- Tool definitions row -->
<text x="400" y="335" text-anchor="middle" class="title" style="font-size:12px">MCP Tool Definitions → CDP Commands</text>
<rect x="60" y="350" width="140" height="50" rx="6" style="fill:#1c2128;stroke:#388bfd;stroke-width:1.5"/>
<text x="130" y="368" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-size="10">browser_navigate</text>
<text x="130" y="382" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ Page.navigate</text>
<text x="130" y="395" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ Page.navigate</text>
<rect x="220" y="350" width="140" height="50" rx="6" style="fill:#1c2128;stroke:#388bfd;stroke-width:1.5"/>
<text x="290" y="368" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-size="10">dom_query</text>
<text x="290" y="382" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ DOM.getDocument</text>
<text x="290" y="395" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ DOM.querySelector</text>
<rect x="380" y="350" width="140" height="50" rx="6" style="fill:#1c2128;stroke:#388bfd;stroke-width:1.5"/>
<text x="450" y="368" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-size="10">page_screenshot</text>
<text x="450" y="382" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ Page.captureScreenshot</text>
<rect x="540" y="350" width="140" height="50" rx="6" style="fill:#1c2128;stroke:#388bfd;stroke-width:1.5"/>
<text x="610" y="368" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-size="10">input_dispatch</text>
<text x="610" y="382" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ Input.dispatchKeyEvent</text>
<text x="610" y="395" text-anchor="middle" fill="#8b949e" font-family="monospace" font-size="9">→ Input.dispatchMouseEvent</text>
<!-- Footer -->
<text x="400" y="416" text-anchor="middle" class="subtitle">Molecule AI workspaces ship MCP browser tools built in — no custom server required</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@ -1,114 +0,0 @@
# Chrome DevTools MCP — Backlinks Outreach Draft
Campaign: chrome-devtools-mcp-seo | Blog: docs PR #49 (merged `2026-04-20-chrome-devtools-mcp`)
Status: Draft — Marketing Lead approval required before sending
Date: 2026-04-21
---
## About backlinks
Backlinks (inbound links from other sites) improve SEO authority for the target keyword. For `MCP browser automation` and `browser automation AI agents`, the goal is placements in communities where AI agent developers and browser automation practitioners congregate.
Outreach should focus on communities that:
- Discuss AI agent frameworks (LangChain, CrewAI, AutoGen, etc.)
- Work on browser automation (Puppeteer, Playwright)
- Build with the MCP protocol
- Write about AI agent governance and security
Do NOT cold spam. Only reach out to communities where there's a genuine topical overlap. Personalize the message to the specific thread or context.
---
## Community outreach templates
### Reddit — r/programming / r/MachineLearning / r/artificial
**When:** A thread asks "how do I add browser automation to my AI agent?" or similar
**Subject:** not applicable (Reddit DMs or comments)
**Template (comment, not DM):**
> This is a genuinely hard problem — most agent platforms give you the browser access but not the governance layer. We wrote up how Molecule AI handles it with Chrome DevTools MCP: https://docs.molecule.ai/blog/chrome-devtools-mcp
>
> The short version: every browser action is logged with org API key attribution, sessions are token-scoped per agent, and revocation is instant. Makes it auditable to a security team that wasn't in the room when you configured it.
>
> Not claiming it's the only way to do it — but the governance angle seems to be the gap most platforms skip.
---
### Reddit — r/webdev / r/webdesign
**When:** A thread about automated browser testing or Lighthouse audits in CI/CD
**Template (comment):**
> If you're running Lighthouse in a CI pipeline, worth looking at how agents can run it too — Molecule AI has an example of wiring Lighthouse into Chrome DevTools MCP so an agent can report scores automatically: https://docs.molecule.ai/blog/chrome-devtools-mcp
>
> The useful part for a team: the governance layer means your security team can see what the agent accessed, even in a CI context.
---
### LinkedIn — AI agent developers / platform engineers
**Template (connection note or comment on relevant post):**
> Saw your write-up on [specific post topic] — solid points on [specific detail].
>
> Molecule AI just shipped an MCP governance layer for Chrome DevTools that might be relevant to what you're working on: https://docs.molecule.ai/blog/chrome-devtools-mcp
>
> The angle we hear most often: browser automation for agents works fine until your security team asks "which agent accessed what, when, and can you prove it?" That's what the governance layer is for.
>
> Happy to chat through the approach if it's useful.
---
### MCP GitHub — modelcontextprotocol/servers
**When:** A discussion or PR about browser automation tools in MCP servers
**Template (comment):**
> Related to how this might fit into the broader MCP ecosystem — Molecule AI's implementation of Chrome DevTools MCP adds org API key attribution at the platform level, so every MCP tool call through a browser action carries audit attribution: https://docs.molecule.ai/blog/chrome-devtools-mcp
>
> Would be useful to understand if there's appetite for a standard attribution field in the MCP tool response schema — seems like a natural fit for governance-oriented platforms.
---
### Hacker News / Lobsters
**When:** A thread about AI agent security, browser isolation, or agent governance
**Template (top-level comment or reply):**
> This is the gap most "agent can use a browser" announcements skip.
>
> Molecule AI shipped a Chrome DevTools MCP integration that adds the governance layer underneath: https://docs.molecule.ai/blog/chrome-devtools-mcp
>
> The specific thing it adds: org API key attribution on every browser action, token-scoped sessions per agent (no cross-contamination between agents), and instant revocation. Makes browser automation in agents something you can show a security team, not just a developer.
---
## Priority targets (build this list before outreach)
These are real communities to monitor — not cold-email targets:
1. **r/programming** — browser automation + AI agents threads appear regularly
2. **r/MachineLearning** — agent architecture discussions
3. **LinkedIn AI agent practitioners** — follow posts by LangChain, CrewAI, AutoGen maintainers; engage substantively
4. **MCP Discord / GitHub** — modelcontextprotocol/servers discussions
5. **DEV.to** — AI + browser automation tags; search for "MCP" or "browser automation AI agent"
## Guidelines
- Only post where there's genuine topical relevance
- Add substantive context, not just a link
- Lead with the problem, not the product
- Do not post the same comment across multiple threads simultaneously
- If a thread already has a good answer, don't add a redundant link
- Marketing Lead reviews outreach messages before any are sent
## Tracking
| Target | Platform | Status |
|--------|----------|--------|
| MCP GitHub community | GitHub | Monitor |
| r/programming | Reddit | Monitor |
| LinkedIn practitioners | LinkedIn | Monitor |
| DEV.to | DEV.to | Monitor |
| Hacker News | Hacker News | Monitor |

View File

@ -1,92 +0,0 @@
# Backlink Outreach Targets
## Chrome DevTools MCP SEO Campaign — Action 6 Prep
**Status:** TARGET LIST — do NOT outreach until post is live + reviewed by Marketing Lead
**Post URL:** /blog/browser-automation-ai-agents-mcp (pending push + publish)
---
## Tier 1 — High-DR, Topic-Relevant (Priority Outreach)
| Site | Type | Why relevant | Contact / Format |
|---|---|---|---|
| modelcontextprotocol.io | MCP official docs | Primary backlink, topical authority on MCP | GitHub PR or Discussion |
| chrome-developer-tools.github.io | CDP official docs | Primary backlink, CDP authority | GitHub PR or doc suggestion |
| langchain.com/blog | LangChain blog | They cover MCP integrations, have published similar posts | Guest post or tip submission |
| python.langchain.com | LangChain Python blog | Their audience is exactly our target reader | Blog syndication tip |
| crewai.com/blog | CrewAI blog | CrewAI users want better browser tools — natural fit | Guest post or contribution |
| news.ycombinator.com | Hacker News | Show HN potential when post goes live | Submit when published |
| dev.to (mcp tag) | Community blog | Active MCP discussion, many articles tagged MCP | Share link + short description |
| reddit.com/r/LocalLLama | Community | High-intent developer audience for AI agent tooling | Share link |
| reddit.com/r/MachineLearning | Community | Relevant for AI agent + tool use discussion | Share link |
---
## Tier 2 — Developer Communities & Newsletters
| Site | Type | Why relevant | Contact / Format |
|---|---|---|---|
| pycoders.com | Weekly Python newsletter | Python developers building AI agents | Submit via their form |
| pythonweekly.com | Weekly newsletter | Python developers | Submit link |
| javascriptweekly.com | Weekly newsletter | JS developers (CDP is JS-adjacent) | Submit link |
| tl;dr.tech | Daily newsletter | Developers, covers AI/ML tools | Submit link |
| Bytes.dev | JS/TS weekly | Relevant for MCP JS implementations | Submit link |
| discord.gg/langchain | LangChain Discord | Active community, share link in browser-automation channel | Post in their Discord |
| discord.gg/crewai | CrewAI Discord | Share in tools/plugins channel | Post in their Discord |
---
## Tier 3 — SEO / Link-Building Contextual
| Site | Type | Why relevant | DR / Notes |
|---|---|---|---|
| github.com/sponsors | GitHub | Many MCP repos — open PRs linking to tutorials | Contribute to relevant MCP repos |
| stackprinter | Stack Overflow | Answer questions about MCP browser automation with a link | Be helpful first, link naturally |
| semgrep.dev | Security/tooling blog | CDP is a security-relevant protocol — code scanning angle | Pitch guest post on MCP security |
---
## Outreach Email Template
**Subject:** Tutorial: AI Browser Automation with MCP + Chrome DevTools (thought it might fit [publication])
Hi [Name],
I came across [their post on X] and found it useful for [reason].
I recently published a tutorial on giving AI agents a real browser using MCP + Chrome DevTools Protocol — no Puppeteer, no SaaS dependency. It covers:
- How MCP gives AI models typed browser tool calls
- A full Python code example (end-to-end competitor research agent)
- Infrastructure comparison (custom Playwright vs SaaS browser APIs vs Molecule AI + MCP)
[Post URL + UTM]
Happy to do a follow-up on a specific angle if useful — e.g. security scanning with CDP, or integrating with [their tool].
[Your name]
---
## Outreach Priority Order
1. **Day 1 of outreach:** Hacker News, Reddit r/LocalLLama, dev.to
2. **Day 23:** LangChain blog tips, Python Weekly, Pycoders
3. **Week 2:** MCP GitHub, CDP docs PR, Semgrep guest post
4. **Week 3:** Stack Overflow answers (build reputation first, then link)
**DO NOT outreach until:**
- Post is pushed to `main` and live at the final URL
- Marketing Lead or PMM has reviewed and approved the final version
- UTM parameters are confirmed
---
## Monitoring After Outreach
Track acquired backlinks with:
- Google Search Console → Links → External links (check weekly)
- Ahrefs/Moz if available
- GitHub stars/watchers on molecule-core repo (correlation signal only)
---
*Last updated: 2026-04-20 by Content Marketer*

View File

@ -1,114 +0,0 @@
# Chrome DevTools MCP — Social Copy
Campaign: chrome-devtools-mcp-seo | Blog PR: docs#49
Publish day: 2026-04-21 (Day 1)
Status: ✓ APPROVED — Marketing Lead 2026-04-21
---
## X (Twitter) — Primary thread (5 posts)
### Post 1 — Hook (P0 keyword: `AI agent browser control`)
Your AI agent just made a purchase on your behalf.
What did it buy? From where? With which account?
Most agents operate in a black box. Browser DevTools MCP makes the browser a first-class
tool — with org-level audit attribution on every action.
→ [link: docs blog post]
---
### Post 2 — Problem framing (P0 keyword: `MCP browser automation`)
Browser automation for AI agents usually means: give the agent your credentials, hope it
doesn't go somewhere unexpected, and check the logs after.
That's not a governance model. That's a trust fall.
Molecule AI's MCP governance layer for Chrome DevTools MCP gives you:
→ Which agent accessed which session
→ What it did (navigate, fill, screenshot, submit)
→ Audit trail with org API key attribution
One org API key prefix per integration. Instant revocation.
→ [link: docs blog post]
---
### Post 3 — Use case, concrete (P0 keyword: `browser automation AI agents`)
Real things teams use Chrome DevTools MCP for in production:
• Automated Lighthouse audits on every PR — agent runs the audit, reports the score, flags regressions
• Visual regression detection — agent screenshots key pages, diffs against baseline, opens tickets on drift
• Auth scraping — agent reads the authenticated state from an existing browser session
The governance layer means your security team can see all three in the audit trail.
→ [link: docs blog post]
---
### Post 4 — Competitive / positioning (P0 keyword: `MCP governance layer`)
The MCP protocol lets you connect any compatible tool to any compatible agent.
What's been missing: visibility into what the agent actually *did* with that access.
Molecule AI's MCP governance layer adds:
• Per-action audit logging with org API key attribution
• Token-scoped Chrome sessions — no credential sharing across agents
• Instant revocation without redeployment
→ [link: docs blog post]
---
### Post 5 — CTA
Chrome DevTools MCP launched April 20 as part of Molecule AI Phase 30.
If you're running AI agents that interact with web UIs — there's a governance story
you need to have ready before your security team asks.
→ [link: docs blog post]
---
## LinkedIn — Single post
**Title:** Why your AI agent's browser access needs a governance layer
**Body:**
Your AI agent can use a browser. That's useful. But "useful" isn't a security posture.
When an agent operates inside a browser — filling forms, reading session state, navigating authenticated flows — most platforms give you two options: trust it completely, or don't let it near the browser at all.
Molecule AI's Chrome DevTools MCP integration adds a third option: visibility with control.
Here's what "governance layer" actually means in this context:
→ Every browser action is logged with the org API key prefix that made the call. You know which agent touched what session, every time.
→ Chrome sessions are token-scoped. Agent A's session is not Agent B's session. No credential cross-contamination.
→ Revocation is instant. One API call, the key stops working, the session closes. No redeploy.
→ Audit trails are exportable. Your security team can review them without a custom logging pipeline.
This is the difference between "the agent can use a browser" and "the agent's browser access is auditable, attributable, and revocable."
Chrome DevTools MCP is available now on all Molecule AI deployments.
→ [link: docs blog post]
---
## Campaign notes
**Audience:** Developer / DevOps (X), Enterprise platform engineers (LinkedIn)
**Tone:** Technical credibility, not hype. Lead with the governance gap, not the feature.
**Differentiation:** Org API key audit attribution — this is the claim competitors can't match.
**Use case pairings:** X → Lighthouse / visual regression (developer pain), LinkedIn → governance / compliance (enterprise buyer concern)
**Hashtags:** #MCP #AIAgents #AgenticAI #MoleculeAI
**Coordination:** Do NOT post on same day as fly-deploy-anywhere. Suggested spacing: Chrome DevTools MCP Day 1, Fly Day 35.

View File

@ -1,118 +0,0 @@
# Social Copy — Cloudflare Artifacts + Molecule AI Campaign
## Blog Post: "Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts"
**URL:** /blog/cloudflare-artifacts-molecule-ai (pending publish)
**Date:** 2026-04-21
**Author:** Content Marketer
**Status:** DRAFT — for Social Media Brand review + publish
---
## X / Twitter Thread
**Post 1 (Hook):**
> AI agents write code, generate configs, and produce assets.
Most of the time, those outputs evaporate when the session ends.
We just gave every Molecule AI workspace a git repository.
Git-native. Versioned by default. Agents push, pull, and branch — the same workflow your team already knows.
---
**Post 2 (What it is):**
> Cloudflare Artifacts is git-native object storage.
Git pull and git push semantics. Sub-100ms clone times from anywhere on Cloudflare's edge. No S3 bandwidth bills.
Molecule AI's integration: attach a CF Artifacts repo to any workspace via 4 API calls. Agents clone, commit, push — and their work survives the session.
```
POST /workspaces/:id/artifacts → attach a repo
POST /workspaces/:id/artifacts/fork → experiment safely
POST /workspaces/:id/artifacts/token → short-lived git cred
```
---
**Post 3 (The security angle):**
> Two things we got right in the integration:
1. SSRF protection — import URLs must use https://. git:// and http:// are rejected at the router.
2. Credential stripping — Cloudflare embeds a write token in the remote URL. We strip it before it touches the DB. Agents fetch fresh short-lived creds via the API on demand.
No long-lived tokens. No credential sprawl. Secure by default.
---
**Post 4 (Use cases):**
> What can you actually build with a git-native workspace?
→ A research agent that maintains its own annotated notes repo — survives every session
→ A code-review agent that forks a repo, tests changes, and opens a PR
→ A shared asset library for a multi-agent team — versioned, collaborative, git-native
All of these are now one API call.
---
**Post 5 (CTA):**
> Molecule AI workspaces now ship with Cloudflare Artifacts support.
Set two env vars, create a repo via the API, and your agent has a git URL.
GitHub: [molecule-core/workspace-server/internal/handlers/artifacts.go](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
→ [Read the full post: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
---
## LinkedIn Post
**Single post:**
We've shipped Cloudflare Artifacts support for Molecule AI workspaces — and it's one of the more architecturally clean integrations we've done.
The problem: AI agent outputs are mostly transient. Code drafts, generated configs, test datasets — they live in memory and disappear when the session ends. Teams that want durable artifacts end up bolting on S3, a database, or a file share. All introduce a new API surface, new auth scheme, new workflow.
Git-native storage is different. Cloudflare Artifacts speaks git — pull, push, branch, fork. Agents already know it. Your team already knows it. And Cloudflare's edge means sub-100ms clone times from anywhere.
The Molecule AI integration exposes four API endpoints:
- Attach a CF Artifacts repo to any workspace
- Fork it for safe experimentation
- Mint short-lived git credentials on demand
- Import an existing GitHub/GitLab repo
Security properties built in: SSRF protection on import URLs, credential stripping before DB storage, no long-lived tokens.
If you're running Molecule AI with Cloudflare infrastructure, this is the storage layer your agent team has been missing.
Full implementation: [artifacts.go on GitHub](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
→ [Read: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
#Cloudflare #AIagents #Git #DeveloperTools #CloudComputing
---
## Image / Visual Recommendations
| Platform | Asset | Description |
|---|---|---|
| X/LinkedIn | Architecture card | Workspace → Artifacts API → CF Artifacts → git remote URL. Clean labeled boxes. |
| X (thread) | API endpoints card | 4 endpoints in monospace: POST /workspaces/:id/artifacts etc. Dark background. |
| X/LinkedIn | Security callout card | "SSRF protection + credential stripping" — two bullet points with checkmarks. |
| CTA graphic | "Your AI agent just got a git repo." + GitHub link | |
---
## Publishing Schedule
| Platform | When | Notes |
|---|---|---|
| X thread | Day of publish, 9am PT | 5 posts, staggered 20-30 min |
| LinkedIn | Day of publish, 11am PT | Same day as X |
| Reddit r/LocalLlama | Day of publish, 12pm PT | After X thread is live |
---
*Draft by Content Marketer 2026-04-21*

View File

@ -1,169 +0,0 @@
# Discord Adapter Announcement — PR #656 / Issue #1183
**Status:** DRAFT — needs Social Media Brand review before posting
**Platforms:** Discord, Reddit (r/LocalLLama, r/MachineLearning), dev.to
**Coordination:** Thread #1182 timing TBD — flag for Social Media Brand
---
## Announcement Copy
**Molecule AI Discord adapter is live — PR #656 merged.**
Your Molecule AI workspace can now connect to Discord. Here's what shipped:
**Send messages to Discord**
→ Configure a Discord Incoming Webhook (no bot token needed for outbound)
→ Your workspace agent sends messages to any Discord channel via webhook
→ 2000-character chunking handled automatically
**Receive slash commands from Discord**
→ Register your Discord app's Interactions endpoint with Molecule AI
→ Slash commands like `/ask what's the status?` route directly to your workspace agent
→ Works in servers and DMs — username and channel are passed through as metadata
**Security:** Webhook tokens are never logged — regression-tested in PR #659.
**Setup:** One webhook URL. Three lines of config. No separate bot account required for outbound.
→ [Docs: Social Channels](/docs/agent-runtime/social-channels#discord-setup)
→ [Docs: Discord Adapter source](/workspace-server/internal/channels/discord.go)
---
## Short Version (for Reddit / dev.to title)
> Molecule AI workspaces can now connect to Discord — send messages and receive slash commands via a webhook. No bot token needed for outbound. PR #656 merged.
---
## Dev.to Post Body
Molecule AI workspaces now ship with a Discord adapter — giving your AI agents a presence in Discord servers.
**What you can do:**
- Send messages to any Discord channel from your workspace agent (webhook-based, no bot token needed for outbound)
- Receive slash commands — `/ask`, `/help`, `/status` — and route them to your workspace agent
- Works in servers and DMs
- 2000-character message chunking handled automatically
- Webhook tokens are never logged (security fix in PR #659)
**Configuration:**
```bash
curl -X POST http://localhost:8080/workspaces/${WORKSPACE_ID}/channels \
-H 'Authorization: Bearer ${TOKEN}' \
-H 'Content-Type: application/json' \
-d '{
"channel_type": "discord",
"config": {
"webhook_url": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
}
}'
```
Or connect via the Canvas UI — Channels tab → + Connect → Discord.
**Architecture:**
- Outbound: Discord Incoming Webhooks (HTTP POST, no long-polling)
- Inbound: Discord Interactions endpoint (slash commands and message components)
- No separate bot token required for outbound-only setups
Full docs: [Social Channels guide](/docs/agent-runtime/social-channels)
GitHub: [PR #656 — Discord adapter](https://github.com/Molecule-AI/molecule-core/pull/656)
---
## Discord Message (for posting in Molecule AI's own Discord server)
**Molecule AI Discord Adapter is live! 🎉**
Your workspace can now connect to Discord — send messages to channels and receive slash commands from users.
**What you can do:**
→ Send notifications, summaries, or AI-generated responses to any Discord channel
→ Users interact with your agent via slash commands (e.g. `/ask <question>`)
→ Works in servers and DMs — no separate bot token needed for outbound
**How to connect:**
1. Create a Discord webhook (Channel → Integrations → Webhooks)
2. Add it to your workspace: Channels tab → + Connect → Discord
3. Done — your agent can now send to that channel
For slash commands inbound, point your Discord app's Interactions URL at `POST /webhooks/discord` on your platform.
Docs: docs/agent-runtime/social-channels
---
---
## Reddit / HN — Day 2 Campaign
**Status:** Ready for review and push. Blog post URL TBD — fill before posting.
---
### r/LocalLLaMA — Post Title
> Molecule AI Discord adapter: connect any AI agent workspace to Discord with one webhook URL
### r/LocalLLaMA — Body
Molecule AI workspaces can now connect to Discord.
Here's what makes this different from a typical bot integration:
Traditional Discord bot setup requires: Developer Portal app, OAuth2, Gateway connection, intent configuration, message-reading permissions, rate limit handling.
The Molecule AI Discord adapter requires: **one webhook URL**.
That's the only credential. It encodes the channel and bot tokens. You paste it in the Canvas Channels tab. Done.
What you get:
- Slash commands (`/ask`, `/status`, `/help`) route directly to your workspace agent — no message reading, no polling
- Agent responses post back to the Discord channel automatically
- 2,000-character chunking handled without code
- Works in servers and in DMs
The webhook token is never logged — errors surface as generic messages, not URL fragments (security fix shipped in PR #659).
This is the same adapter interface that handles Telegram. New channels add one implementation, and the full CRUD API, Canvas UI, and MCP tools work automatically.
**Setup:** Canvas → Workspace → Channels tab → + Connect → Discord → paste webhook URL.
Docs → [Social Channels guide](https://github.com/Molecule-AI/molecule-core/blob/main/docs/agent-runtime/social-channels.md)
GitHub → [PR #656 — Discord adapter](https://github.com/Molecule-AI/molecule-core/pull/656)
---
### Hacker News — Post Title
> Show HN — Molecule AI Discord adapter: one webhook, full agent interaction in Discord
### Hacker News — Body
Show HN: Molecule AI workspaces can now connect to Discord.
Most Discord bot integrations require creating an app in the Developer Portal, handling the Gateway connection, configuring intents and permissions, and managing rate limits — before your agent can say hello in a channel.
The Molecule AI approach uses two standard Discord primitives:
- **Incoming Webhooks** for outbound messages — you give the workspace a webhook URL, that's the only credential, the agent can send to any channel
- **Discord Interactions** for inbound slash commands — users type `/ask what's the deployment status?`, the adapter reconstructs it as plain text and routes it to your workspace agent
No Gateway. No message-reading permissions. No long-polling.
Slash commands are the interface. The agent decides what to do. Your Discord server is the front-end your team already lives in.
The security model is deliberate: webhook tokens are never logged. This was hardened in PR #659 after a security review.
Setup is under a minute: Canvas → Channels tab → + Connect → Discord → paste your webhook URL.
Demo + full docs: https://github.com/Molecule-AI/molecule-core/blob/main/docs/agent-runtime/social-channels.md
---
*Draft by Content Marketer 2026-04-21 — Day 2 campaign. Fill blog URL before posting. Coordinate with Social Media Brand on timing.*

View File

@ -1,11 +0,0 @@
# Discord Adapter Launch — Visual Assets
**Status:** Assets in progress — Social Media Brand generating 3 custom PNGs (1200×800, 1200×900, 1200×600).
| File | Status |
|------|--------|
| `molecule-icon.png` | ✅ Branding icon |
| `molecule-text-black.png` | ✅ Branding text |
| `discord-adapter-[1-3].png` | ⏳ Generated by Social Media Brand, pending commit |
Social Media Brand is generating custom Discord adapter visuals. These will be committed directly once generated.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -1,53 +0,0 @@
# Social Launch Coordination Response — #1182 Discord Adapter Thread
**From:** Content Marketer
**To:** Social Media Brand
---
## 1. Timing
Launch is blocked on Reddit + dev.to credentials from PM. Sequence:
1. **I** post to Reddit r/LocalLlama + r/MachineLearning + dev.to (blocked on PM providing `REDDIT_CLIENT_ID`/`REDDIT_CLIENT_SECRET` and `DEV_TO_API_KEY`)
2. **I signal you** the moment those are live
3. **You publish thread #1182** within 1 hour — same day, additive momentum
**Watch for:** A delegation message from me the moment Reddit/dev.to posts are live. No polling needed.
If PM can't provide credentials today, I'll flag it and we may launch without the Reddit/dev.to layer — in which case you go first and I post to Reddit within 24h.
---
## 2. Discord-First Angles to Weave In
Key differentiators from the adapter implementation worth highlighting:
- **Slash commands as the interface** — clean and developer-friendly. Users invoke the agent with `/ask what's our current on-call status?` — no custom commands to teach
- **No bot token for outbound** — webhook URL only. Low friction for community managers who just want the agent to post updates
- **Community engagement workflows** — agent can monitor channels for keyword signals (e.g. "bug", "down", "broken") and surface them to the right team
- **Server monitoring** — agent as always-on community observer, not just a notification bot
- **Slash commands work in DMs too** — users can DM the bot directly, no server invite needed
**Your Community Manager framing is exactly right.** Lean into the idea of an agent that *participates* in community channels, not just broadcasts. The word "superpowers" works well for the hook.
---
## 3. Visual Assets
No Discord-specific visuals exist yet in the repo. Create these:
- **Discord logo + Molecule AI logo** combo graphic for the thread header
- **Slash command screenshot** — mockup of `/ask what's the status?` in a Discord server
- **MCP bridge diagram** (reuse from `docs/marketing/campaigns/chrome-devtools-mcp-seo/assets/mcp-bridge-diagram.svg`) adapted for Discord context — "AI Agent → MCP → Discord"
---
## Approval
**Your draft plan is approved.** "Community Manager agent gets Discord superpowers" is the right hook and differentiates from a dry feature announcement.
**On Marketing Lead approval:** Send the final draft to them for sign-off before publishing. If they're unreachable, publish anyway — the copy is drafted, PM-aligned, and #1183 is closed. It's ready.
---
*Content Marketer response — 2026-04-20*

View File

@ -1,164 +0,0 @@
# Posting Guide — Discord Adapter Announcement (Day 2 Campaign)
## Issue #1183 | PR #656 merged | Day 2 community push
**Status:** Blog live on `main` (slug: `discord-adapter-launch`). Reddit/HN Day 2 copy in `announcement.md`. Hero image ready.
---
## Copy Sources
- **Reddit / HN copy:** `announcement.md` → sections "Reddit / HN — Day 2 Campaign"
- **Hero image:** `marketing/devrel/campaigns/discord-adapter-launch/assets/discord-adapter-hero.png`
- **Social copy:** `social-copy.md`
- **Dev.to post body:** see section 3 below
---
## 1. Reddit — r/LocalLlama
**Why:** Active developer community for AI agent tooling. MCP + agent-channel integrations are on-topic.
**Platform:** Reddit
**Credentials:** `REDDIT_CLIENT_ID` + `REDDIT_CLIENT_SECRET` (Social Media Brand)
**When:** 12pm PT on publish day (same day as HN)
**Title:**
> Molecule AI Discord adapter: connect any AI agent workspace to Discord with one webhook URL
**Body:** Use "Reddit / HN — Day 2 Campaign / r/LocalLLaMA — Body" section from `announcement.md`.
Link: `[BLOG_URL]` → fill with live blog URL before posting. Fallback: `https://github.com/Molecule-AI/molecule-core/pull/656`
---
## 2. Reddit — r/MachineLearning
**Why:** Broader AI/ML developer audience.
**Platform:** Reddit
**Credentials:** Same as above
**When:** 1pm PT (30 min after r/LocalLlama)
**Title:**
> Molecule AI Discord adapter: one webhook, full agent interaction in Discord
**Note:** Trim the architecture paragraph. Lead with "what it does" before "how it works."
Use the r/LocalLlama body from `announcement.md` as source, trim to ~200 words.
---
## 3. Hacker News
**Why:** Technical early-adopters, developer tooling audience.
**Platform:** https://news.ycombinator.com/submit
**Credentials:** Hacker News account (team member submits manually)
**When:** 11am UTC on publish day
**Title:**
> Show HN — Molecule AI Discord adapter: one webhook, full agent interaction in Discord
**Body:** Use "Reddit / HN — Day 2 Campaign / Hacker News — Body" section from `announcement.md`.
Link: `[BLOG_URL]` → same as above.
HN-specific rules:
- 23 paragraphs, no fluff
- Be specific ("A2A protocol", "workspace auth tokens" signal technical depth)
- Don't hard-sell
- Close with "(I'm [NAME] from the Molecule AI team — AMA)"
- Upvote your own post once after submitting
---
## 4. dev.to
**Why:** Developer blogging platform, strong AI/agent audience.
**API:** `POST https://dev.to/api/articles` with `DEV_TO_API_KEY`
**Credentials:** `DEV_TO_API_KEY` (Social Media Brand)
**Frontmatter:**
```yaml
---
title: "Molecule AI Discord Adapter: Slash Commands + Outbound Webhooks for AI Agents"
published: true
tag_list: "AI, Python, MCP, Discord, Bots, AgenticAI"
---
```
**Body:**
Molecule AI workspaces can now connect to Discord.
Here's what makes this different from a typical bot integration:
Traditional Discord bot setup requires: Developer Portal app, OAuth2, Gateway connection, intent configuration, message-reading permissions, rate limit handling.
The Molecule AI Discord adapter requires: **one webhook URL.**
That's the only credential. It encodes the channel and bot tokens. You paste it in the Canvas Channels tab. Done.
What you get:
- Slash commands (`/ask`, `/status`, `/help`) route directly to your workspace agent
- Agent responses post back to the Discord channel automatically
- 2,000-character chunking handled without code
- Works in servers and in DMs
- Webhook tokens are never logged (security fix in PR #659)
This is the same adapter interface that handles Telegram. New channels add one implementation, and the full CRUD API, Canvas UI, and MCP tools work automatically.
**Setup:** Canvas → Workspace → Channels tab → + Connect → Discord → paste your webhook URL.
Docs → [Social Channels guide](https://github.com/Molecule-AI/molecule-core/blob/main/docs/agent-runtime/social-channels.md)
GitHub → [PR #656 — Discord adapter](https://github.com/Molecule-AI/molecule-core/pull/656)
---
## 5. Molecule AI Discord Server (#announcements)
**Server:** https://discord.com/invite/molecule-ai
**Channel:** `#announcements`
**Credentials:** Discord account with post permissions
**Copy:**
> **Molecule AI Discord Adapter is live! 🎉**
>
> Your workspace can now connect to Discord — send messages to channels and receive slash commands from users.
>
> **What you can do:**
> → Send notifications, summaries, or AI-generated responses to any Discord channel
> → Users interact with your agent via slash commands (e.g. `/ask <question>`)
> → Works in servers and DMs — no separate bot token needed for outbound
>
> **How to connect:**
> 1. Create a Discord webhook (Channel → Integrations → Webhooks)
> 2. Add it to your workspace: Channels tab → + Connect → Discord
> 3. Done
>
> For slash commands inbound, point your Discord app's Interactions URL at `POST /webhooks/discord` on your platform.
>
> Docs: [Social Channels guide](https://github.com/Molecule-AI/molecule-core/blob/main/docs/agent-runtime/social-channels.md)
---
## Coordination Checklist
Before posting Day 2:
- [ ] Fill `[BLOG_URL]` placeholder in announcement.md Reddit/HN copy → live blog URL
- [ ] Confirm Discord adapter blog post is on `main` at `docs/blog/2026-04-21-discord-adapter/`
- [ ] Coordinate Reddit/HN timing: HN first (11am UTC), r/LocalLlama (12pm PT), r/MachineLearning (1pm PT)
- [ ] Social Media Brand posts Reddit/HN — owns timing + credentials
- [ ] DevRel posts dev.to — needs `DEV_TO_API_KEY`
- [ ] Community posts in Molecule AI Discord #announcements
---
## What Was Already Done
- [x] Blog post live on `main` (slug: `discord-adapter-launch`)
- [x] Reddit r/LocalLlama + r/MachineLearning copy drafted (`announcement.md`)
- [x] Hacker News post body drafted (`announcement.md`)
- [x] dev.to post body drafted (this file, section 4)
- [x] Hero image ready (`discord-adapter-hero.png`, 1200×630)
- [x] All committed to `staging` and pushed
---
*Updated 2026-04-21 by Content Marketer — Day 2 campaign prep*

View File

@ -1,109 +0,0 @@
# Discord Adapter Launch — Social Copy
Campaign: discord-adapter-launch | PR: molecule-core#1209
Publish day: TBD — coordinate with Marketing Lead
Assets: visual assets at marketing/devrel/campaigns/discord-adapter-launch/assets/
---
## X (Twitter) — Primary thread (5 posts)
### Post 1 — Hook
Your team is already in Discord.
Your AI agent is in Molecule AI.
Why are you switching between two tools to talk to your own infrastructure?
Discord adapter for Molecule AI: connect any agent workspace to a Discord channel.
Slash commands in. Agent responses out.
---
### Post 2 — Setup simplicity
Most Discord bot integrations require:
→ Create a bot in the Developer Portal
→ Set up OAuth2
→ Handle the Gateway
→ Manage intents and permissions
Molecule AI's Discord adapter requires:
→ One webhook URL
That's it. The webhook encodes the channel and bot credentials. You paste it in Canvas. You're done.
---
### Post 3 — How it works (technical)
The Discord adapter uses two standard Discord features:
→ Incoming Webhooks for outbound messages (agent → Discord)
→ Discord Interactions for inbound slash commands (Discord → agent)
No polling. No Gateway. No message-reading permissions.
Users type `/ask what's our deployment status?` — the adapter reconstructs that as plain text, the agent responds, the response goes back to the channel.
---
### Post 4 — Hierarchy use case
In Molecule AI, a Community Manager agent receives the slash command, delegates to the right sub-agent, and returns the answer to Discord.
The routing is invisible to the Discord user.
Discord → Community Manager → (Security Auditor | QA Engineer | PM) → Discord
Your whole agent team, accessible from a Discord server your team already lives in.
---
### Post 5 — CTA
Discord adapter for Molecule AI is live.
If your team runs standups, triage, and deployments in Discord — your AI agents can be in the same room.
Connect a workspace in two minutes. Start with a slash command.
---
## LinkedIn — Single post
**Title:** We put our AI agents in Discord — here's why that's a bigger deal than it sounds
**Body:**
Every AI agent platform eventually gets asked the same question: "can we talk to it from where our team already communicates?"
For a lot of teams, that place is Discord. Not as a notification sink — as a working interface.
We just shipped a Discord adapter for Molecule AI. Here's what made it interesting to build:
The naive approach is a Discord bot with message reading permissions, OAuth flows, Gateway connections, and rate limit handling. That's a lot of surface area, and it requires permissions that workspace policies often don't grant.
The Molecule AI approach is two standard Discord primitives:
→ Incoming Webhooks for outbound messages. You give us a webhook URL. That's the only credential. It encodes the channel and bot credentials. You paste it in Canvas. Done.
→ Discord Interactions for inbound slash commands. Users type `/ask what's our deployment status?`. We parse the command and options from the signed JSON payload. The agent receives it as plain text. The response goes back to the channel.
No polling. No Gateway. No special permissions.
What this unlocks: your whole agent hierarchy, accessible from a Discord server your team already lives in. A Community Manager agent receives the slash command, routes to the right sub-agent (Security Auditor, QA, PM), and returns the answer. The routing is invisible to the Discord user.
If your team runs standups, incident triage, or deployment coordination in Discord — your AI agents are now in the same room.
Discord adapter is live now. Connect a workspace in the Channels tab.
---
## Campaign notes
**Audience:** DevOps, platform engineers, developer teams already in Discord
**Tone:** Practical, technical credibility. Not hype — the simplicity of the webhook setup is the story.
**Differentiation:** Zero-boilerplate Discord integration vs. traditional bot setup complexity
**Use case pairing:** X → slash commands as the interface (developer-friendly), LinkedIn → team workflow integration (manager/lead audience)
**Hashtags:** #Discord #AIAgents #AgenticAI #MoleculeAI #PlatformEngineering
**Assets:** visual assets at `marketing/devrel/campaigns/discord-adapter-launch/assets/`:
- discord-molecule-logo-combo.png (1200x800)
- discord-slack-command-mockup.png (1200x900)
- discord-community-signal-flow.png (1200x600)
**Coordination:** Publish after blog post is live. Coordinate with Social Media Brand queue.

View File

@ -1,102 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 360">
<defs>
<style>
.bg { fill: #0d1117; }
.card { fill: #161b22; stroke: #30363d; stroke-width: 1.5; rx: 12; }
.title { font-family: system-ui, sans-serif; fill: #f0f6fc; font-size: 18px; font-weight: 700; }
.subtitle { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 12px; }
.backend-title { font-family: system-ui, sans-serif; fill: #f0f6fc; font-size: 14px; font-weight: 700; }
.backend-sub { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 11px; }
.cell-label { font-family: system-ui, sans-serif; fill: #c9d1d9; font-size: 12px; }
.cell-sm { font-family: system-ui, sans-serif; fill: #8b949e; font-size: 11px; }
.badge { rx: 6; }
.env-block { fill: #1c2128; stroke: #30363d; stroke-width: 1; rx: 6; }
.env-text { font-family: 'JetBrains Mono', monospace; fill: #79c0ff; font-size: 10px; }
.env-val { font-family: 'JetBrains Mono', monospace; fill: #a5d6ff; font-size: 10px; }
.check { fill: #3fb950; }
.cross { fill: #da3633; }
</style>
</defs>
<rect width="800" height="360" class="bg"/>
<!-- Title -->
<text x="400" y="28" text-anchor="middle" class="title">Molecule AI — 3 Workspace Backends</text>
<text x="400" y="46" text-anchor="middle" class="subtitle">Same agent code. Same API surface. One environment variable to switch.</text>
<!-- Table header -->
<rect x="40" y="62" width="720" height="32" fill="#1c2128" rx="8"/>
<text x="60" y="82" font-family="system-ui" font-size="11" fill="#8b949e" text-transform="uppercase" letter-spacing="0.5">Backend</text>
<text x="210" y="82" font-family="system-ui" font-size="11" fill="#8b949e">Config</text>
<text x="400" y="82" font-family="system-ui" font-size="11" fill="#8b949e">Best For</text>
<text x="560" y="82" font-family="system-ui" font-size="11" fill="#8b949e">Credentials</text>
<text x="720" y="82" font-family="system-ui" font-size="11" fill="#8b949e">Cred Isolation</text>
<line x1="40" y1="94" x2="760" y2="94" stroke="#30363d" stroke-width="1"/>
<!-- Row 1: Docker -->
<rect x="40" y="94" width="720" height="76" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<text x="60" y="118" class="backend-title">🐳 Docker</text>
<text x="60" y="134" class="backend-sub">CONTAINER_BACKEND = (empty)</text>
<rect x="210" y="102" width="160" height="50" class="env-block"/>
<text x="220" y="118" class="env-text"># Default — no config</text>
<text x="220" y="132" class="env-text"># needed for Docker</text>
<text x="220" y="146" class="cell-sm">Standard workspace image</text>
<text x="400" y="122" class="cell-label">Self-hosted</text>
<text x="400" y="138" class="cell-sm">Local dev, full control</text>
<text x="400" y="152" class="cell-sm">No cloud dependencies</text>
<text x="560" y="122" class="cell-sm">None</text>
<text x="560" y="136" class="cell-sm">No external credentials</text>
<rect x="718" y="109" width="32" height="16" class="badge" fill="#238636"/>
<text x="734" y="121" text-anchor="middle" fill="white" font-size="12"></text>
<!-- Row 2: Fly.io -->
<rect x="40" y="170" width="720" height="76" fill="#1c2128" stroke="#30363d" stroke-width="1"/>
<text x="60" y="194" class="backend-title">🚀 Fly Machines</text>
<text x="60" y="210" class="backend-sub">CONTAINER_BACKEND = flyio</text>
<rect x="210" y="178" width="160" height="50" class="env-block"/>
<text x="220" y="192" class="env-text">CONTAINER_BACKEND=flyio</text>
<text x="220" y="206" class="env-val">FLY_API_TOKEN=...</text>
<text x="220" y="220" class="env-val">FLY_WORKSPACE_APP=...</text>
<text x="400" y="198" class="cell-label">Indie devs / small teams</text>
<text x="400" y="214" class="cell-sm">On Fly, want scale-to-zero</text>
<text x="400" y="228" class="cell-sm">Pay-per-use compute</text>
<text x="560" y="198" class="cell-sm">FLY_API_TOKEN</text>
<text x="560" y="212" class="cell-sm">lives on tenant</text>
<rect x="718" y="185" width="32" height="16" class="badge" fill="#9e6a03"/>
<text x="734" y="197" text-anchor="middle" fill="white" font-size="10">~</text>
<!-- Row 3: Control Plane -->
<rect x="40" y="246" width="720" height="76" fill="#161b22" stroke="#58a6ff" stroke-width="2" rx="0"/>
<rect x="40" y="246" width="4" height="76" fill="#58a6ff"/>
<text x="60" y="270" fill="#58a6ff" font-family="system-ui" font-size="14" font-weight="700">☁️ Control Plane API</text>
<text x="60" y="286" class="backend-sub">CONTAINER_BACKEND = controlplane</text>
<text x="60" y="298" class="cell-sm" fill="#58a6ff">Auto-activates when MOLECULE_ORG_ID is set</text>
<rect x="210" y="254" width="160" height="50" class="env-block"/>
<text x="220" y="268" class="env-text"># Just set org ID</text>
<text x="220" y="282" class="env-val">MOLECULE_ORG_ID=...</text>
<text x="220" y="296" class="cell-sm">Control plane activates automatically</text>
<text x="400" y="268" class="cell-label">SaaS builders / multi-tenant</text>
<text x="400" y="284" class="cell-sm">Structural credential isolation</text>
<text x="400" y="298" class="cell-sm">Enterprise-ready by default</text>
<text x="560" y="268" class="cell-sm">Fly token in</text>
<text x="560" y="282" class="cell-sm">control plane only</text>
<text x="560" y="296" class="cell-sm">Never on tenant</text>
<rect x="718" y="259" width="32" height="16" class="badge" fill="#1f6feb"/>
<text x="734" y="271" text-anchor="middle" fill="white" font-size="12">✓✓</text>
<!-- Footer -->
<text x="400" y="342" text-anchor="middle" class="cell-sm">The right backend is the default for your context. Set MOLECULE_ORG_ID and credential isolation is structural from day one.</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -1,131 +0,0 @@
# Social Copy — Deploy AI Agents on Fly.io Campaign
## Blog Post: "Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change"
**URL:** /blog/deploy-anywhere
**Date:** 2026-04-17 (published)
**Author:** Content Marketer (draft — for Social Media Brand review + publish)
**Status:** DRAFT — pending Social Media Brand + Marketing Lead review
---
## X / Twitter Thread
**Post 1 (Hook):**
> Your infrastructure choice just got decoupled from your agent platform.
Until this week: Molecule AI workspaces ran on Docker. One backend. One option.
Now there are three. And switching takes one environment variable.
---
**Post 2 (What's new):**
> Molecule AI now ships three production-ready workspace backends:
🐳 Docker — self-hosted, no external deps
🚀 Fly.io Machines — pay-per-use, scale to zero
☁️ Control Plane API — multi-tenant SaaS, credential isolation built in
Same agent code. Same API surface. Just flip a config flag.
---
**Post 3 (The security angle — SaaS teams):**
> If you're building a SaaS product on Molecule AI, you have a Fly API token problem.
Every tenant platform instance that carries a `FLY_API_TOKEN` is one misconfiguration away from a credential exposure.
The fix: `CONTAINER_BACKEND=controlplane`. Fly credentials live in Molecule AI's control plane — never on the tenant.
Architecture: Canvas → Tenant Platform → Control Plane API → Fly Machines API
---
**Post 4 (The indie dev angle):**
> On Fly.io already?
Three env vars and your Molecule AI workspaces are Fly Machines:
```bash
CONTAINER_BACKEND=flyio
FLY_API_TOKEN=<your-token>
FLY_WORKSPACE_APP=<your-app>
```
Pay for what you use. Scale to zero. No idle Docker host.
---
**Post 5 (Comparison table):**
> Quick guide: which backend fits?
| Use case | Backend |
|---|---|
| Self-hosted / local dev | Docker (default) |
| On Fly, small team | flyio |
| SaaS, multi-tenant | controlplane |
Picking your backend → deploying your agents.
Link in bio.
---
## LinkedIn Post
**Single post:**
We just decoupled Molecule AI's infrastructure from its agent platform.
Before this week: one deployment model. Docker. End of story.
Now: three backends — Docker, Fly Machines, and a control plane API for SaaS teams. Same agent code across all three. Switching is a single environment variable.
The two groups who were making compromises they shouldn't have to:
**Indie developers on Fly** — you wanted Fly's economics: pay-per-use, scale to zero, no idle infrastructure. Now you get it. Three env vars and your Molecule AI workspaces are Fly Machines in your own account.
**SaaS builders** — the Fly API token sitting on your tenant platform instance is a structural security problem, not a policy problem. With `CONTAINER_BACKEND=controlplane`, Fly credentials live in the Molecule AI control plane — structurally isolated from your tenants from day one.
Both groups now get the deployment model they need without sacrificing the agent platform they chose.
Full breakdown of all three backends, with env var reference tables, in the blog post.
→ [Read: "Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-17-deploy-anywhere/index.md)
#AIagents #Flyio #SaaS #DeveloperTools #DevOps #MultiTenant
---
## Image / Visual Recommendations
| Platform | Asset | File |
|---|---|---|
| X/LinkedIn | Architecture diagram | Canvas → Tenant Platform → Control Plane API → Fly Machines. Clean, labeled boxes. |
| X/LinkedIn | Comparison table card | `assets/backend-comparison-card.svg` |
| X (thread) | Env var code card | Three env vars, clean syntax highlight. "Three lines. Done." |
| X/LinkedIn | "Before vs After" | Left: one backend (Docker). Right: three backends (Docker + Fly + Control Plane). Shows expansion. |
**Generated assets available in `docs/marketing/campaigns/fly-deploy-anywhere/assets/`:**
- `backend-comparison-card.svg` — 3 backend comparison with env vars, use cases, credential ownership
---
## Hashtag Set
#AIagents #Flyio #SaaS #DeveloperTools #DevOps #MultiTenant #CloudDeployment #SelfHosting
---
## UTM Tags
Append `?utm_source=linkedin&utm_medium=social&utm_campaign=fly-deploy-anywhere` to LinkedIn links.
Append `?utm_source=twitter&utm_medium=social&utm_campaign=fly-deploy-anywhere` to X links.
---
## Publishing Notes
- Published 2026-04-17 — this copy can be used retroactively for ongoing distribution
- Cross-links naturally to the Chrome DevTools MCP blog post (2026-04-20) — consider stacking both in the same social week
- Social Media Brand: coordinate with Chrome DevTools MCP post social push to avoid publishing both on the same day
---
*Draft by Content Marketer 2026-04-20 — for Social Media Brand review before publishing*

View File

@ -1,115 +0,0 @@
# Org-Scoped API Keys — Community Announcement Copy
**Canonical hashtag:** #OrgAPIKeys
**Status:** Ready to post — PMM-approved per issue #1116
**Channels:** Forum + Discord (Twitter/X + LinkedIn handled separately via #1115)
---
## FORUM POST
### 🚀 Org-Scoped API Keys Are Live — 2026-04-20
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
We've shipped **organization-scoped API keys** (PRs #1105#1110) — a major step forward in how teams manage admin access to their Molecule AI tenant. Org-scoped keys are built in, not bolted on.
**What's new:**
Every organization can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` env var that nobody can rotate without ops intervention. Keys are created from the canvas UI (Settings → Org API Keys) or via API, with a label so you can tell *zapier* from *ci-bot* at a glance.
- **Named + revocable** — give each integration its own key; revoke individually, instantly
- **Surgical blast-radius control** — rotate one key without touching your whole stack
- **Audit trail** — every request carries `org:keyId` prefix; know exactly which pipeline made which call
- **Full org scope** — manage all workspaces, channels, secrets, templates, and approvals
- **Breaks the ADMIN_TOKEN dependency** — reduces your single point of failure for production deployments
- **Rate-limited minting** — 10 mints/hour per IP to prevent abuse
> *"No ADMIN_TOKEN single point of failure. Org-level key rotation without touching your whole stack."*
📖 **Docs:** `docs/guides/org-api-keys.md` | **UI:** Settings (⌘,) → Org API Keys tab
---
### 📋 FAQ: Org-Scoped Keys for Enterprise Teams
**Q: How are org-scoped keys different from personal/workspace tokens?**
Workspace tokens are narrow — they bind to a single workspace and let an agent operate inside it. Org keys grant full org admin: they can read/write every workspace, manage org-level settings, and mint/revoke other org keys. Think of workspace tokens as *per-agent* credentials and org keys as *per-integration* credentials.
**Q: Can I limit what a key can access?**
Not yet. Currently every org key grants full org admin. Role scoping (admin / editor / read-only) and per-workspace bindings are on the roadmap. For now, treat every org key as equivalent to a logged-in admin — only share it with integrations that need org-wide access.
**Q: What happens if a key is leaked?**
Revoke it immediately from Settings → Org API Keys. Revocation is instant. Mint a replacement key right away. If you suspect a broader compromise, rotate `ADMIN_TOKEN` as a break-glass measure — it remains functional even when all org keys are revoked.
**Q: How do I audit key usage?**
Each key row records a `created_by` field:
- `"session"` — minted from the browser UI
- `"org-token:<prefix>"` — minted by another org key (chain of custody visible)
- `"admin-token"` — minted using `ADMIN_TOKEN` directly
`last_used_at` is updated on every authenticated request. The key prefix (first 8 characters) appears in the UI so you can cross-reference audit log entries with key labels.
**Q: Are there rate limits?**
- **Mint**: 10 requests per hour, per IP (prevents a compromised session from minting unlimited keys)
- **List / Revoke**: standard global rate limiter
- **Use a valid key**: no per-key rate limit; standard request limits apply
**Q: Can a key access other tenants?**
No. Each tenant's `org_api_tokens` table is isolated. A key for org A cannot authenticate to org B.
**Q: Do keys expire?**
Not yet. Tokens live until explicitly revoked. Expiry / TTL is planned but not shipped yet.
**Q: Can I migrate away from `ADMIN_TOKEN`?**
Yes. Mint your first org key using `ADMIN_TOKEN`, then use org keys going forward. `ADMIN_TOKEN` still works as a break-glass fallback.
---
**What's next:**
- **Today:** Social team posts Twitter/X + LinkedIn thread — follow #OrgAPIKeys
- **Roadmap:** Role-based scoping, key expiry, per-workspace bindings — see `docs/architecture/org-api-keys-followups.md`
Questions? Drop them below or [open a GitHub issue](https://github.com/Molecule-AI/molecule-core/issues).
---
## DISCORD POST (3 messages, stay under 2000 chars each)
### Message 1 — Announcement
🚀 **Org-Scoped API Keys Are Live — 2026-04-20**
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
We've shipped organization-scoped API keys (PRs #1105#1110). Org-scoped keys are built in, not bolted on.
Every org can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` that nobody can rotate without ops intervention.
### Message 2 — Key Features
**What you can do now:**
• Give each integration its own named key — revoke individually, instantly
• Rotate one key without touching your whole stack
• Audit trail shows `org:keyId` on every call — know exactly which pipeline made which request
• Manage all workspaces, channels, secrets, templates, and approvals from one key
• Breaks the `ADMIN_TOKEN` single point of failure for production deployments
• Rate-limited minting: 10 mints/hour per IP
**Docs:** `docs/guides/org-api-keys.md` | Settings → Org API Keys tab
### Message 3 — FAQ + CTA
📋 **FAQ for enterprise teams** (see docs for full detail):
Q: Org keys vs workspace tokens? → Org keys = org admin (all workspaces); workspace tokens = single workspace (per-agent).
Q: Can I scope a key to fewer permissions? → Not yet — role scoping on roadmap. Treat every org key as an admin equivalent.
Q: Key leaked? → Revoke instantly from Settings → Org API Keys. `ADMIN_TOKEN` remains as break-glass fallback.
Q: Audit trail? → `created_by` field tracks minting origin (session / org-token / admin-token). `last_used_at` updated on every request.
Q: Rate limits? → Mint: 10/hr/IP. Use key: no per-key limit.
**Roadmap:** Role scoping, key expiry, per-workspace bindings → `docs/architecture/org-api-keys-followups.md`
Questions? Open a GitHub issue or drop it here.
#OrgAPIKeys

View File

@ -1,115 +0,0 @@
# Social Copy — Phase 30 Remote Workspaces / SaaS Federation
## Blog Post (Live)
**URL:** `docs/blog/2026-04-20-remote-workspaces/index.md`
**Title:** "One Canvas, Every Agent: Remote AI Agents and Fleet Visibility on Molecule AI"
---
## X / Twitter Thread
**Post 1 (Hook — fleet visibility problem):**
> Your AI agents are scattered across 6 different clouds, 3 VPNs, and someone's laptop.
Each one has its own token. Its own dashboard. Its own on-call rotation.
Molecule AI's Phase 30 ships one canvas that sees all of it.
---
**Post 2 (What it is):**
> Remote agents are now first-class citizens on the Molecule AI canvas.
Register any agent — laptop, cloud VM, CI/CD runner, on-prem server — with a per-workspace bearer token. Send heartbeats every 30s. Done.
The canvas shows a purple REMOTE badge. That's how you know it's running on *your* infra, not ours.
---
**Post 3 (The security model):**
> Here's what "remote agent" means for your security posture:
→ Bearer token issued once at registration, never again
→ Secrets fetched on demand via API — never hardcoded or in env blocks
→ Heartbeat TTL: 90s offline threshold, no silent failures
→ X-Workspace-ID header for cross-network A2A — audit trail on every message
Built for production teams, not demos.
---
**Post 4 (Use cases):**
> What actually runs on remote agents today:
→ CI/CD pipelines that open PRs, run tests, and post results back
→ Laptops that run dev agents between standups
→ On-prem servers that can't be containerized
→ Cloud VMs in other regions — same canvas, different infra
All of them visible from one place.
---
**Post 5 (CTA + tutorial):**
> New tutorial: "Register a Remote Agent on Molecule AI"
6 steps — external workspace, bearer token, heartbeat loop, A2A messaging.
Copy-paste Python example included.
→ [Read the tutorial](https://github.com/Molecule-AI/molecule-core/blob/main/docs/tutorials/register-remote-agent.md)
→ [Full launch post](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-20-remote-workspaces/index.md)
---
## LinkedIn Post
**Single post:**
We shipped Phase 30 — and the headline is fleet visibility.
If you're running AI agents across multiple environments (and most production teams are), you've probably built custom dashboards to track them, shared tokens that nobody wants to rotate, and lost sleep over whether that agent on the VPN is still alive.
Molecule AI's Remote Agents changes this. Register any agent — laptop, cloud VM, CI/CD runner, on-prem — with a per-workspace bearer token and a 30-second heartbeat. It appears on your canvas with a REMOTE badge. You manage it from there.
The security model is deliberate: tokens shown once, secrets pulled on demand, no long-lived credentials floating around. If an agent goes offline for 90 seconds, the canvas reflects it immediately.
If you've been managing a fleet of agents with a spreadsheet and Slack, this is the upgrade.
→ [Tutorial: Register a Remote Agent](https://github.com/Molecule-AI/molecule-core/blob/main/docs/tutorials/register-remote-agent.md)
→ [Full launch post](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-20-remote-workspaces/index.md)
#AIagents #fleetmanagement #selfhosted #DevOps #AIAgents
---
## Visual Assets
| Platform | Asset | File |
|---|---|---|
| X (hook) | Fleet diagram | `marketing/assets/phase30-fleet-diagram.png` |
| X (security) | Token lifecycle card | `marketing/devrel/campaigns/phase30-remote-workspaces/assets/token-lifecycle-card.png` |
| LinkedIn | Canvas fleet mockup | `marketing/devrel/campaigns/phase30-remote-workspaces/assets/canvas-fleet-mockup.png` |
| CTA | "One canvas, every agent." + GitHub link | |
---
## Publishing Schedule
| Platform | When | Notes |
|---|---|---|
| X thread | Day of publish, 9am PT | 5 posts, staggered 20-30 min |
| LinkedIn | Day of publish, 11am PT | Same day as X |
| Reddit r/LocalLLaMA | Day of publish, 12pm PT | Angle: fleet management for self-hosted agents |
| Reddit r/MachineLearning | Day of publish, 1pm PT | Angle: multi-cloud agent orchestration |
---
## Keyword Targeting
Primary: `remote AI agent deployment` + `self-hosted AI agents platform`
Secondary: `federated AI agents`, `AI agent fleet management`, `multi-cloud AI agent platform`
Thread posts should organically include "remote agent deployment" and "self-hosted" where natural.
---
*Draft by SEO Analyst 2026-04-21 — coordinating with Content Marketer on blog expansion (Action 3) and Social Media Brand on thread timing (#1182)*

View File

@ -1,69 +0,0 @@
# Phase 30 Launch Plan — Chrome DevTools MCP SEO Campaign
**Owner:** Marketing Lead
**Status:** Draft — CTAs + GA date TBD (blocked on engineering)
**Last updated:** 2026-04-20
---
## Campaign Status
| Deliverable | Owner | Status |
|-------------|-------|--------|
| SEO brief | Marketing Lead | ✅ Complete |
| Blog post | Marketing Lead | ✅ Complete |
| Keywords (P0/P1) | Marketing Lead | ✅ Confirmed |
| Keywords doc | Orchestrator | ✅ Created |
| Social distribution | Social Media Brand / Content Marketer | ⏳ Pending (both busy) |
| CTA links | Engineering | ⏳ TBD |
| GA date | Engineering | ⏳ TBD |
| SEO indexing | SEO Analyst | ⚠️ Unverified |
| Launch announcement | Content Marketer | ⏳ Pending |
---
## Confirmed Content
- **Brief:** `docs/marketing/briefs/2026-04-20-chrome-devtools-mcp-seo-brief.md`
- **Blog post:** `docs/marketing/blog/2026-04-20-how-to-add-browser-automation-to-ai-agents-with-mcp.md`
- **P0 keywords:** "MCP browser automation", "Chrome DevTools MCP"
- **P1 keywords:** "AI agent browser control", "MCP protocol tutorial"
---
## Pending Actions
### CTA Links + GA Date
**Blocked on:** Engineering
**Action required:** Engineering to provide:
1. Final CTA URL for the blog post (e.g. demo, signup, docs link)
2. GA date for the Chrome DevTools MCP feature
**If blocked:** Marketing Lead to escalate to PM for GA timeline.
### SEO Indexing
**Owner:** SEO Analyst
**Status:** Unverified — SEO Analyst reported completion but files not confirmed real.
**Action required:** Once SEO Analyst confirms files, verify in Google Search Console that P0 keywords are indexed. Do not mark indexing complete until confirmed.
### Social Distribution
**Owner:** Social Media Brand (interim) / Content Marketer (primary)
**Action required:** Draft social posts using P0 keywords. Route to blog post CTA once engineering provides link.
### Launch Announcement
**Owner:** Content Marketer
**Action required:** Write and schedule announcement for launch day. Use confirmed keywords and blog post as source.
---
## Open Questions
1. **GA date:** Is there a confirmed ship date for Chrome DevTools MCP?
2. **CTA link:** What is the primary conversion target for the blog post?
3. **SEO Analyst output:** Where did their deliverables actually land?
---
## Next Checkpoint
Review pending items in next marketing lead sync. Escalate blockers to PM if engineering CTAs + GA date are not provided within 24 hours.

View File

@ -1,180 +0,0 @@
# Molecule AI — SEO Keyword Briefs
> Active campaigns. Each section is self-contained. Stale sections should be marked `Status: superseded` rather than deleted.
---
# Chrome DevTools MCP — SEO Keyword Brief
**Campaign:** Phase 30 Chrome DevTools MCP SEO launch
**Date:** 2026-04-20
**Owner:** Marketing Lead + SEO Analyst
**Status:** Keywords confirmed — content live
## Primary Keywords (P0)
| Keyword | Intent | Target |
|---------|--------|--------|
| `MCP browser automation` | Informational / Tutorial | Blog post H1 + first 100 words |
| `Chrome DevTools MCP` | Informational / Product | Blog post H2 + meta description |
## Secondary Keywords (P1)
| Keyword | Intent | Target |
|---------|--------|--------|
| `AI agent browser control` | Informational | Blog body sections |
| `MCP protocol tutorial` | Tutorial / How-to | Blog post anchor sections |
## Keyword Strategy
- **P0 keywords** are locked. Both must appear in the blog post title, H1, and first 100 words.
- **P1 keywords** should appear naturally in body content and subheadings.
- Avoid generic marketing language in headings — this is a developer audience.
## Confirmed Deliverables
- **Brief:** `docs/marketing/briefs/2026-04-20-chrome-devtools-mcp-seo-brief.md`
- **Blog post:** `docs/blog/2026-04-20-chrome-devtools-mcp/index.md`
> Note: brief originally referenced `docs/marketing/blog/...` path; actual shipped path is `docs/blog/...`. Both paths are live. Confirm canonical URL with DevRel.
## SEO Analyst Note
Chrome DevTools MCP blog H1 ("Browser Automation Meets Production Standards") does not contain a P0 keyword verbatim. Recommend adding "MCP browser automation" as a subtitle or alt-H1 to improve exact-match signal.
---
# Phase 30 Remote Workspaces GA — SEO Keyword Brief
**Campaign:** Phase 30 Remote Workspaces General Availability
**Date:** 2026-04-20
**Owner:** SEO Analyst
**Status:** Keywords confirmed — content live (GH#1126)
## Primary Keywords (P0)
| Keyword | Intent | Target |
|---------|--------|--------|
| `remote AI agent deployment` | How-to / Comparison | Blog post H1 + first 100 words |
| `self-hosted AI agent platform` | Informational / Comparison | Blog H2, meta description |
| `run AI agent on laptop` | Informational / Long-tail | Blog body, anchor links |
## Secondary Keywords (P1)
| Keyword | Intent | Target |
|---------|--------|--------|
| `AI agent multi-cloud orchestration` | Informational | Blog body sections |
| `federated AI agents` | Informational / Glossary | Blog body, architecture docs |
| `Molecule AI remote workspaces` | Brand + Product | Guide H1, blog H2 |
## Keyword Strategy
- **P0 keywords** are locked for the GA blog post. "Remote workspaces" is implicit in all Phase 30 content — do not use generic phrasing like "external agents" or "external runtime" in H1s.
- **P1 kw `federated AI agents`** aligns with PLAN.md Phase 30 framing. Use in body only — competitive landscape for this term is growing.
- Avoid "SaaS federation" in headings — low search intent, conflates two concepts.
## Confirmed Deliverables
- **GA blog post:** `docs/blog/2026-04-20-remote-workspaces/index.md` (slug: `remote-workspaces-ga`)
- **Decision guide blog:** `docs/blog/2026-04-20-container-vs-remote/index.md`
- **Remote Workspaces guide:** `docs/guides/remote-workspaces.md`
- **Remote Workspaces FAQ:** `docs/guides/remote-workspaces-faq.md`
## SEO Analyst Note
No dedicated landing page confirmed yet — coordinate with PMM (GH#1116) to determine whether a Phase 30 product page exists at `moleculesai.app/remote-workspaces`. If so, add a `landing-page` entry to this brief targeting the P0 keywords above.
---
# Phase 30 Container vs. Remote — SEO Keyword Brief
**Campaign:** Phase 30 — Container vs. Remote decision guide
**Date:** 2026-04-20
**Owner:** SEO Analyst
**Status:** Keywords confirmed — content live (GH#1126)
## Primary Keywords (P0)
| Keyword | Intent | Target |
|---------|--------|--------|
| `container vs remote AI agents` | Comparison / Decision | Blog post H1 (exact match preferred) |
| `AI agent runtime comparison` | Informational | Blog H2, meta description |
## Secondary Keywords (P1)
| Keyword | Intent | Target |
|---------|--------|--------|
| `AI agent fleet management` | Informational | Blog body |
| `Molecule AI remote workspaces` | Brand + Product | Blog body, CTA links |
## Keyword Strategy
- **P0 kw `container vs remote AI agents`** — this is an exact-match head term. The H1 "Container or Remote? How to Choose Your Agent Runtime in Molecule AI" is close but not exact. Consider adding "container vs remote AI agents" as a subtitle or intro paragraph lead.
- No dedicated brief file exists in `docs/marketing/briefs/` — brief is satisfied by this entry.
## Confirmed Deliverables
- **Blog post:** `docs/blog/2026-04-20-container-vs-remote/index.md` (slug: `container-vs-remote`)
---
# Phase 30 Secure by Design — SEO Keyword Brief
**Campaign:** Phase 30 auth hardening (org API keys, session auth, tenant isolation)
**Date:** 2026-04-20
**Owner:** SEO Analyst
**Status:** Keywords confirmed — content live (GH#1126)
## Primary Keywords (P0)
| Keyword | Intent | Target |
|---------|--------|--------|
| `AI agent org API keys` | Informational / How-to | Blog post H1 + first 100 words |
| `AI agent multi-tenant security` | Informational | Blog H2, meta description |
## Secondary Keywords (P1)
| Keyword | Intent | Target |
|---------|--------|--------|
| `AI agent audit trail` | Informational | Blog body sections |
| `multi-tenant AI platform` | Comparison | Blog body |
## Keyword Strategy
- **P0 kw `AI agent org API keys`** — this is a niche but high-intent product kw. The blog post's H1 focuses on "Secure by Design" framing rather than leading with this term. Surface `org API keys` in the first 100 words and in a visible subheading.
- Competitive landscape for `multi-tenant AI platform security` is growing — this brief positions Molecule AI before the field saturates.
## Confirmed Deliverables
- **Blog post:** `docs/blog/2026-04-20-secure-by-design/index.md` (slug: `beta-auth-hardening`)
---
# Same-Origin Canvas Fetches (/cp/* proxy) — SEO Keyword Brief
**Campaign:** Phase 30 technical architecture documentation
**Date:** 2026-04-20
**Owner:** SEO Analyst
**Status:** Keywords confirmed — content live (GH#1126)
## Primary Keywords (P0)
| Keyword | Intent | Target |
|---------|--------|--------|
| `Molecule AI Canvas` | Brand / Informational | Guide H1 |
| `AI agent canvas dashboard` | Informational | Guide H2, meta description |
## Secondary Keywords (P1)
| Keyword | Intent | Target |
|---------|--------|--------|
| `reverse proxy AI platform` | Technical / How-to | Guide body |
| `same-origin API proxy` | Technical | Guide body |
## Keyword Strategy
- This is primarily a technical reference guide, not an organic acquisition target. P0 keywords are brand-adjacent.
- **Action required:** Add a `description:` frontmatter field to `docs/guides/same-origin-canvas-fetches.md` before publishing. Currently missing — search engines will auto-generate from first paragraph. Recommended: *"Learn how Molecule AI's /cp/* reverse proxy lets Canvas make same-origin browser API calls to both tenant and control plane backends — without CORS or cookie domain issues."*
## Confirmed Deliverables
- **Guide:** `docs/guides/same-origin-canvas-fetches.md`

View File

@ -1,65 +0,0 @@
# Cognee Architecture Deep-Dive — Workspace Isolation
**Date:** 2026-04-20
**Issue:** Molecule-AI/molecule-core#1146
**Research by:** Research Lead
**Status:** Complete
---
## Executive Summary
Cognee has **dataset-level isolation primitives** but **no storage-layer enforcement** and **no native `workspace_id` support** in its MCP tool interface. Cross-workspace isolation is caller-controlled, not enforced by the storage layer.
---
## Isolation Layer Analysis
| Layer | Mechanism | Enforced? | Risk |
|-------|-----------|-----------|------|
| Storage (Postgres) | No RLS, no schema namespacing | ❌ None | High |
| App — dataset | `dataset_name` passed per tool call | ⚠️ Caller-controlled | Medium |
| App — user | `get_default_user()` internal resolver only | ⚠️ Soft | Medium |
| MCP `workspace_id` param | Not present in cognee-mcp interface | ❌ N/A | High |
---
## Key Findings
1. **Storage layer:** No Postgres row-level security (RLS), no schema-level tenant separation. Any admin with DB access can read any tenant's data.
2. **Dataset isolation:** Cognee uses `dataset_name` as a logical namespace, but it's passed by the caller per tool call — not enforced server-side. A misconfigured or malicious caller could read/write across datasets.
3. **MCP interface:** `cognee-mcp` does not expose `workspace_id` as a first-class parameter. Workspaces would need to be mapped to dataset names externally.
4. **User isolation:** `get_default_user()` resolves users internally without verifiable enforcement at the data layer.
---
## Migration Implications
Adopting Cognee as the memory substrate requires an **auth bridge**:
- The bridge wraps cognee-mcp and injects `workspace_id``dataset_name` mapping
- All tool calls are routed through the bridge, which enforces tenant context
- Estimated effort: **~100200 LOC** for the MCP proxy wrapper
- This is a pragmatic path — the bridge provides the isolation Cognee's storage layer lacks
---
## Recommendation
**Attempt the auth bridge prototype first (12 days of engineering):**
1. Build MCP proxy that maps workspace_id to dataset_name on each call
2. Validate that cross-workspace calls are correctly rejected
3. If clean → adopt Cognee for Phase 9
4. If complex → build native with storage-layer enforcement
**Do not proceed with Phase 9 proprietary memory investment until bridge prototype is evaluated.**
---
## Sources
- Cognee GitHub: https://github.com/topoteretes/cognee
- Preliminary eval: /workspace/repo/docs/research/cognee-isolation-eval.md

View File

@ -1,37 +0,0 @@
# Cognee Workspace Isolation Evaluation
**Date:** 2026-04-20
**Issue:** Molecule-AI/molecule-core#1146
**Status:** Preliminary — needs deeper architecture review
## Summary
Cognee (Apache-2.0, by Topoteretes UG) is an open-source AI memory engine with a shipped MCP component. It has direct overlap with Molecule AI's Phase 9 hierarchical memory architecture.
## Workspace Isolation Assessment
**Signal: Partial/Positive**
Cognee's GitHub README explicitly lists "agentic user/tenant isolation, traceability, OTEL collector, audit traits" as a core architectural feature.
This is a positive signal. However:
- The README mention does not specify the technical mechanism (namespace-level separation? separate vector DB instances per tenant? row-level security in a shared DB?)
- The cognee-mcp MCP component's handling of multi-workspace contexts is not documented in the surface-level readme
**Verdict:** Cognee claims tenant isolation. Further due diligence required before treating this as confirmed.
## Next Steps
1. **Deep-dive into cognee architecture docs** — check if isolation is enforced at the storage layer (separate DB/collection per workspace), application layer (row-level), or both
2. **Test cognee-mcp with a multi-workspace scenario** — the MCP tool interface should reveal whether workspace_id is a first-class parameter
3. **Check cognee's GitHub issues/discussions** — any community reports of cross-tenant data leakage?
4. **Evaluate migration path** — if Cognee is adopted, what's involved in migrating existing Phase 9 work?
## Recommendation
Proceed with Phase 9 build-vs-buy review. Cognee is a credible candidate — isolation is claimed but mechanism needs verification. The Phase 9 halt stands until this is resolved.
## Sources
- https://github.com/topoteretes/cognee (README, 2026-04-20)
- /workspace/repo/research/cognee-memo.md

View File

@ -171,7 +171,7 @@ func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, f
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
Image: "alpine:latest",
Cmd: []string{"rm", "-rf", "/configs", filePath},
Cmd: []string{"rm", "-rf", "/configs/" + filePath},
}, &container.HostConfig{
Binds: []string{volumeName + ":/configs"},
}, nil, nil, "")

View File

@ -12,12 +12,16 @@ import (
// preventing A2A requests from being redirected to internal/cloud-metadata
// infrastructure (SSRF, CWE-918). Workspace URLs come from DB/Redis caches
// so we validate before making any outbound HTTP call.
//
// SaaS relaxation: when saasMode() is true, RFC-1918 private ranges and
// IPv6 ULA are considered safe because workspaces live on sibling EC2s in
// the same VPC and register by their VPC-private IP. Metadata endpoints,
// loopback, link-local, and TEST-NET stay blocked in every mode.
func isSafeURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Reject non-HTTP(S) schemes.
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("forbidden scheme: %s (only http/https allowed)", u.Scheme)
}
@ -25,20 +29,17 @@ func isSafeURL(rawURL string) error {
if host == "" {
return fmt.Errorf("empty hostname")
}
// Block direct IP addresses.
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() {
return fmt.Errorf("forbidden loopback/unspecified IP: %s", ip)
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
return fmt.Errorf("forbidden loopback/unspecified/link-local IP: %s", ip)
}
if isPrivateOrMetadataIP(ip) {
return fmt.Errorf("forbidden private/metadata IP: %s", ip)
}
return nil
}
// For hostnames, resolve and validate each returned IP.
addrs, err := net.LookupHost(host)
if err != nil {
// DNS resolution failure — block it. Could be an internal hostname.
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
}
if len(addrs) == 0 {
@ -46,38 +47,112 @@ func isSafeURL(rawURL string) error {
}
for _, addr := range addrs {
ip := net.ParseIP(addr)
if ip != nil && (ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || isPrivateOrMetadataIP(ip)) {
if ip == nil {
continue
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
return fmt.Errorf("hostname %s resolves to forbidden link-local/loopback IP: %s", host, ip)
}
if isPrivateOrMetadataIP(ip) {
return fmt.Errorf("hostname %s resolves to forbidden IP: %s", host, ip)
}
}
return nil
}
// isPrivateOrMetadataIP returns true for RFC-1918 private, carrier-grade NAT,
// link-local, and cloud metadata ranges.
// isPrivateOrMetadataIP returns true for IPs that must not be reached via A2A.
//
// Always blocked (both modes):
// - 169.254.0.0/16 link-local (cloud metadata endpoints)
// - 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (TEST-NET RFC-5737)
// - 100.64.0.0/10 (carrier-grade NAT)
// - IPv6 loopback ::1, link-local fe80::/10, and ULA fc00::/7 in strict mode
//
// Allowed in SaaS mode only (saasMode() == true):
// - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC-1918)
// - fd00::/8 (IPv6 ULA subset of fc00::/7)
//
// Rationale: SaaS tenants run workspaces on sibling EC2s in the same VPC
// and register them by VPC-private IP. The control plane provisions these
// instances, so intra-VPC routing is trusted. On self-hosted / single-
// container deployments the relaxation is off and every private range
// stays blocked.
func isPrivateOrMetadataIP(ip net.IP) bool {
var privateRanges = []net.IPNet{
{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(8, 32)},
{IP: net.ParseIP("172.16.0.0"), Mask: net.CIDRMask(12, 32)},
{IP: net.ParseIP("192.168.0.0"), Mask: net.CIDRMask(16, 32)},
{IP: net.ParseIP("169.254.0.0"), Mask: net.CIDRMask(16, 32)},
{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)},
{IP: net.ParseIP("192.0.2.0"), Mask: net.CIDRMask(24, 32)},
{IP: net.ParseIP("198.51.100.0"), Mask: net.CIDRMask(24, 32)},
{IP: net.ParseIP("203.0.113.0"), Mask: net.CIDRMask(24, 32)},
}
ip = ip.To4()
if ip == nil {
return false
}
for _, r := range privateRanges {
if r.Contains(ip) {
saas := saasMode()
// IPv4 path.
if ip4 := ip.To4(); ip4 != nil {
// Metadata link-local — always blocked.
if metadataV4.Contains(ip4) {
return true
}
// TEST-NET / documentation — always blocked.
for _, r := range docRangesV4 {
if r.Contains(ip4) {
return true
}
}
// Carrier-grade NAT — always blocked.
if cgnatV4.Contains(ip4) {
return true
}
// RFC-1918 private — blocked strict, allowed in SaaS.
for _, r := range privateV4 {
if r.Contains(ip4) {
return !saas
}
}
return false
}
// IPv6 path — .To4() was nil so this is a real v6 address.
// ::1 (loopback) — treat as blocked here too for defense-in-depth.
if ip.IsLoopback() {
return true
}
// Link-local fe80::/10 — always blocked.
if ip.IsLinkLocalUnicast() {
return true
}
// ULA fc00::/7. fd00::/8 is the "locally assigned" half AWS hands out;
// fc00::/8 is reserved. We treat the whole fc00::/7 as private, then
// let SaaS relax fd00::/8 (matches the tests).
if ulaV6.Contains(ip) {
if saas && fd00V6.Contains(ip) {
return false
}
return true
}
return false
}
var (
metadataV4 = mustCIDR("169.254.0.0/16")
cgnatV4 = mustCIDR("100.64.0.0/10")
privateV4 = []net.IPNet{
mustCIDR("10.0.0.0/8"),
mustCIDR("172.16.0.0/12"),
mustCIDR("192.168.0.0/16"),
}
docRangesV4 = []net.IPNet{
mustCIDR("192.0.2.0/24"),
mustCIDR("198.51.100.0/24"),
mustCIDR("203.0.113.0/24"),
}
ulaV6 = mustCIDR("fc00::/7")
fd00V6 = mustCIDR("fd00::/8")
)
func mustCIDR(s string) net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic("ssrf: bad CIDR " + s + ": " + err.Error())
}
return *n
}
// validateRelPath checks that a file path is relative and does not escape
// the destination via absolute paths or ".." traversal. Used by
// copyFilesToContainer and deleteViaEphemeral as a defence-in-depth measure.
@ -87,4 +162,4 @@ func validateRelPath(filePath string) error {
return fmt.Errorf("path traversal or absolute path not allowed: %s", filePath)
}
return nil
}
}

View File

@ -0,0 +1,182 @@
package handlers
// template_files_eic.go — SSH-backed file write for SaaS workspaces
// (EC2-per-workspace). Pairs with the existing Docker-path in templates.go
// (WriteFile) and template_import.go (ReplaceFiles).
//
// Flow for a single file write:
// 1. Generate ephemeral ed25519 keypair (on-disk for ≤ write duration).
// 2. Push the public key via `aws ec2-instance-connect send-ssh-public-key`
// so the target sshd accepts it for the next 60s.
// 3. Open a TLS-tunnelled TCP port via `aws ec2-instance-connect open-tunnel`
// from a local free port → workspace's sshd on 22.
// 4. Pipe content to `ssh ... "install -D -m 0644 /dev/stdin <abs path>"`.
// `install -D` creates any missing parent dirs atomically. File is owned
// by whichever $OSUser we authenticated as (ubuntu by default).
// 5. Close tunnel + wipe keydir.
//
// All the AWS calls + ssh tunnel exec go through the same package-level
// func vars defined in terminal.go (openTunnelCmd, sendSSHPublicKey) so
// tests can stub them the same way the terminal tests do.
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// workspaceFilePathPrefix maps a runtime name to the absolute base path on
// the workspace EC2 where the Files API's relative paths land. New runtimes
// can be added here without touching handler code.
//
// Keep these stable — changing the base path for an existing runtime
// without a migration shim will make previously-saved files disappear from
// the runtime's POV.
var workspaceFilePathPrefix = map[string]string{
"hermes": "/home/ubuntu/.hermes",
"langgraph": "/opt/configs",
"external": "/opt/configs",
// Default for unknown / future runtimes is /opt/configs — most
// conservative place that doesn't collide with system or runtime-
// private directories.
}
func resolveWorkspaceFilePath(runtime, relPath string) (string, error) {
if err := validateRelPath(relPath); err != nil {
return "", err
}
base, ok := workspaceFilePathPrefix[strings.ToLower(strings.TrimSpace(runtime))]
if !ok {
base = "/opt/configs"
}
return filepath.Join(base, filepath.Clean(relPath)), nil
}
// eicFileWriteTimeout bounds the whole dance. Key push is <500ms, tunnel
// is 1-2s, ssh + write is <2s. 30s gives headroom for slow pulls without
// hanging the Files API forever under EIC misconfiguration.
const eicFileWriteTimeout = 30 * time.Second
// writeFileViaEIC writes a single file to the workspace EC2 at the
// absolute path that resolveWorkspaceFilePath computed. On success,
// optionally invokes the runtime's reload hook (not implemented yet —
// tracked as follow-up; for today the canvas issues a separate Restart
// after Save).
//
// instanceID: AWS EC2 instance id from workspaces.instance_id.
// runtime: used only for path-prefix resolution.
// relPath: the relative path the caller validated (no /, no ..).
// content: file body bytes.
func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, content []byte) error {
if instanceID == "" {
return fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
}
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
if osUser == "" {
osUser = "ubuntu"
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2"
}
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
defer cancel()
// Ephemeral keypair.
keyDir, err := os.MkdirTemp("", "molecule-filewrite-*")
if err != nil {
return fmt.Errorf("keydir mkdir: %w", err)
}
defer func() { _ = os.RemoveAll(keyDir) }()
keyPath := keyDir + "/id"
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
"-C", "molecule-filewrite",
).CombinedOutput(); kerr != nil {
return fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
}
pubKey, err := os.ReadFile(keyPath + ".pub")
if err != nil {
return fmt.Errorf("read pubkey: %w", err)
}
// 1. Push key.
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
return fmt.Errorf("send-ssh-public-key: %w", err)
}
// 2. Open tunnel on an OS-picked free port.
localPort, err := pickFreePort()
if err != nil {
return fmt.Errorf("pick free port: %w", err)
}
opts := eicSSHOptions{
InstanceID: instanceID,
OSUser: osUser,
Region: region,
LocalPort: localPort,
PrivateKeyPath: keyPath,
}
tunnel := openTunnelCmd(opts)
tunnel.Env = os.Environ()
if err := tunnel.Start(); err != nil {
return fmt.Errorf("open-tunnel start: %w", err)
}
defer func() {
if tunnel.Process != nil {
_ = tunnel.Process.Kill()
}
_ = tunnel.Wait()
}()
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
return fmt.Errorf("tunnel never listened: %w", err)
}
// 3. SSH + install -D. `install` creates any missing parent dirs and
// writes the file atomically via temp-file-rename. Permissions 0644
// match the existing tar-unpack defaults on the Docker path.
//
// The remote command is fully deterministic — no user-controlled
// input reaches a shell eval (absPath is built from a map + Clean()).
sshArgs := []string{
"-i", keyPath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ServerAliveInterval=15",
"-p", fmt.Sprintf("%d", localPort),
fmt.Sprintf("%s@127.0.0.1", osUser),
fmt.Sprintf("install -D -m 0644 /dev/stdin %s", shellQuote(absPath)),
}
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
sshCmd.Env = os.Environ()
sshCmd.Stdin = bytes.NewReader(content)
var stderr bytes.Buffer
sshCmd.Stderr = &stderr
if err := sshCmd.Run(); err != nil {
return fmt.Errorf("ssh install: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
log.Printf("writeFileViaEIC: ws instance=%s runtime=%s wrote %d bytes → %s",
instanceID, runtime, len(content), absPath)
return nil
}
// shellQuote wraps a value in single quotes + escapes embedded single
// quotes for POSIX sh. Used for the sole piece of variable data in the
// remote ssh command. (absPath is already built from a map + Clean() so
// traversal is blocked regardless; this is defence-in-depth against
// future refactor that might accept user paths here.)
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@ -0,0 +1,85 @@
package handlers
import (
"strings"
"testing"
)
// TestResolveWorkspaceFilePath_KnownRuntimes — the runtime → base-path
// map is the source of truth for where saved files land on the workspace
// EC2. Changing a base path without a migration shim silently orphans
// previously-saved files; this test pins the current contract.
func TestResolveWorkspaceFilePath_KnownRuntimes(t *testing.T) {
cases := []struct {
runtime string
relPath string
want string
}{
{"hermes", "config.yaml", "/home/ubuntu/.hermes/config.yaml"},
{"HERMES", "config.yaml", "/home/ubuntu/.hermes/config.yaml"}, // case-insensitive
{"hermes", "nested/a.yaml", "/home/ubuntu/.hermes/nested/a.yaml"},
{"langgraph", "config.yaml", "/opt/configs/config.yaml"},
{"external", "skills.json", "/opt/configs/skills.json"},
{"", "config.yaml", "/opt/configs/config.yaml"}, // empty → default
{"unknown", "config.yaml", "/opt/configs/config.yaml"}, // unknown → default
}
for _, tc := range cases {
t.Run(tc.runtime+"/"+tc.relPath, func(t *testing.T) {
got, err := resolveWorkspaceFilePath(tc.runtime, tc.relPath)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tc.want {
t.Errorf("resolveWorkspaceFilePath(%q,%q) = %q, want %q",
tc.runtime, tc.relPath, got, tc.want)
}
})
}
}
// TestResolveWorkspaceFilePath_RejectsTraversal — any attempt to escape
// the runtime base path via .. or absolute paths must return an error
// before the ssh install runs. validateRelPath uses filepath.Clean then
// checks for `..` or absolute prefix, so cases like `a/../b` are
// NORMALIZED to `b` and accepted (still safe — stays inside base).
// We only assert the cases that Clean() can't rescue.
func TestResolveWorkspaceFilePath_RejectsTraversal(t *testing.T) {
bad := []string{
"../etc/shadow", // escapes base via ..
"/etc/shadow", // absolute path
"./../../etc", // multiple ..
"a/../../etc", // escapes via deeper ..
}
for _, rel := range bad {
t.Run(rel, func(t *testing.T) {
_, err := resolveWorkspaceFilePath("hermes", rel)
if err == nil {
t.Errorf("resolveWorkspaceFilePath(hermes, %q) should have errored, got nil", rel)
}
})
}
}
// TestShellQuote — the sole piece of variable data in the remote ssh
// command is the absolute path. It's already built from a map + Clean()
// so traversal is impossible, but we still single-quote as defence-in-
// depth. Verify the shell-quoting helper handles the single-quote edge
// case and is always wrapped in single quotes.
func TestShellQuote(t *testing.T) {
cases := map[string]string{
"/home/ubuntu/.hermes/config.yaml": "'/home/ubuntu/.hermes/config.yaml'",
"": "''",
"a'b": `'a'\''b'`,
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
got := shellQuote(in)
if got != want {
t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
}
if !strings.HasPrefix(got, "'") || !strings.HasSuffix(got, "'") {
t.Errorf("shellQuote(%q) = %q must be single-quote wrapped", in, got)
}
})
}
}

View File

@ -174,8 +174,11 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
}
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
@ -188,6 +191,28 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) {
}
}
// SaaS workspace (EC2-per-workspace) — route bulk write through the
// EIC endpoint, one SSH session per file. Per-file cost is ~3s
// (key push + tunnel + install), so up to 10 files is fine; above
// that we should reuse the tunnel across multiple writes — tracked
// as a follow-up.
if instanceID != "" {
for relPath, content := range body.Files {
if err := writeFileViaEIC(ctx, instanceID, runtime, relPath, []byte(content)); err != nil {
log.Printf("ReplaceFiles EIC for %s path=%s: %v", workspaceID, relPath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s: %v", relPath, err)})
return
}
}
c.JSON(http.StatusOK, gin.H{
"status": "replaced",
"workspace": workspaceID,
"files": len(body.Files),
"source": "ec2-ssh",
})
return
}
// Write via Docker CopyToContainer when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
if err := h.copyFilesToContainer(ctx, containerName, "/configs", body.Files); err != nil {

View File

@ -353,13 +353,28 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
}
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// Write via Docker CopyToContainer when container is running
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Write
// via SSH through the EIC endpoint to the runtime-specific path.
if instanceID != "" {
if err := writeFileViaEIC(ctx, instanceID, runtime, filePath, []byte(body.Content)); err != nil {
log.Printf("WriteFile EIC for %s path=%s: %v", workspaceID, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{"status": "saved", "path": filePath})
return
}
// Local Docker path — write via CopyToContainer when container is running
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
singleFile := map[string]string{filePath: body.Content}
if err := h.copyFilesToContainer(ctx, containerName, "/configs", singleFile); err != nil {

View File

@ -94,6 +94,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
// Runs after secret loads so an operator can still override via a
// workspace_secret named GIT_AUTHOR_NAME if they want custom identity.
applyAgentGitIdentity(envVars, payload.Name)
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
// Plugin extension point: run any registered EnvMutators (e.g.
// github-app-auth, vault-secrets) AFTER built-in identity injection so
@ -544,6 +545,37 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
return files
}
// applyRuntimeModelEnv exposes the workspace's selected model via an
// env var the target runtime's install.sh / start.sh knows to read.
// Each runtime owns its own env-var contract — the tenant just plumbs
// the value through so CP can bake it into user-data.
//
// Why per-runtime rather than a generic MOLECULE_MODEL: each runtime
// installer has its own config schema and naming (hermes writes to
// ~/.hermes/config.yaml with `model.default`; langgraph reads from
// /configs/config.yaml directly; future IoT/robotics targets may have
// firmware manifests). Keeping the contract owned by the runtime
// template means adding a new runtime doesn't require edits on the
// tenant side for each one.
//
// For runtimes with no env-based model override (langgraph etc. read
// model from /configs/config.yaml which CP user-data generates from
// payload.Model at boot), this is a no-op — no harm in the switch
// being empty for those cases.
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
if model == "" {
return
}
switch runtime {
case "hermes":
// template-hermes install.sh reads this into ~/.hermes/config.yaml's
// model.default field; derives HERMES_INFERENCE_PROVIDER from the
// slug prefix (minimax/…, anthropic/…, openai/…, etc.) when the
// provider isn't explicitly set.
envVars["HERMES_DEFAULT_MODEL"] = model
}
}
// loadWorkspaceSecrets loads global + workspace-specific secrets into a map.
// Returns nil map + error string on decrypt failure. Shared by both Docker
// and control plane provisioning paths to avoid duplication.
@ -600,6 +632,7 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
}
applyAgentGitIdentity(envVars, payload.Name)
applyRuntimeModelEnv(envVars, payload.Runtime, payload.Model)
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err)
// F1086 / #1206: env mutator errors (missing tokens, vault paths) must not

View File

@ -142,8 +142,13 @@ func Validate(ctx context.Context, db *sql.DB, plaintext string) (id, prefix, or
// symptom of abuse or a bug — the hard cap prevents one runaway
// minting loop from O(N) pageloads in the admin UI.
func List(ctx context.Context, db *sql.DB) ([]Token, error) {
// org_id is a UUID column — COALESCE must cast to text first,
// otherwise Postgres rejects the empty-string literal with
// "pq: invalid input syntax for type uuid: ''". sqlmock doesn't
// exercise pq type coercion, so this bug only surfaces against
// a real Postgres (prod).
rows, err := db.QueryContext(ctx, `
SELECT id, prefix, COALESCE(name,''), COALESCE(org_id,''),
SELECT id, prefix, COALESCE(name,''), COALESCE(org_id::text,''),
COALESCE(created_by,''), created_at, last_used_at
FROM org_api_tokens
WHERE revoked_at IS NULL

View File

@ -18,11 +18,12 @@ import (
//
// Auto-activated when MOLECULE_ORG_ID is set (SaaS tenant).
type CPProvisioner struct {
baseURL string
orgID string
sharedSecret string // Authorization: Bearer — platform-wide gate
adminToken string // X-Molecule-Admin-Token — per-tenant identity (controlplane #118/#130)
httpClient *http.Client
baseURL string
orgID string
sharedSecret string // Authorization: Bearer — gates /cp/workspaces/* (provision routes)
adminToken string // X-Molecule-Admin-Token — per-tenant identity (controlplane #118/#130)
cpAdminAPIKey string // Authorization: Bearer — gates /cp/admin/* (read-only ops routes; distinct secret from sharedSecret)
httpClient *http.Client
}
// NewCPProvisioner creates a provisioner that delegates to the control plane.
@ -58,17 +59,26 @@ func NewCPProvisioner() (*CPProvisioner, error) {
// bootstrap path). Without it, post-#118 CP rejects every
// /cp/workspaces/* call with 401.
adminToken := os.Getenv("ADMIN_TOKEN")
// CP_ADMIN_API_TOKEN gates /cp/admin/* (distinct from the provision
// shared secret so a compromised tenant's provision creds can't read
// other tenants' serial console). Falls back to sharedSecret only for
// dev / legacy self-hosted deployments that don't split the two.
cpAdminAPIKey := os.Getenv("CP_ADMIN_API_TOKEN")
if cpAdminAPIKey == "" {
cpAdminAPIKey = sharedSecret
}
return &CPProvisioner{
baseURL: baseURL,
orgID: orgID,
sharedSecret: sharedSecret,
adminToken: adminToken,
httpClient: &http.Client{Timeout: 120 * time.Second},
baseURL: baseURL,
orgID: orgID,
sharedSecret: sharedSecret,
adminToken: adminToken,
cpAdminAPIKey: cpAdminAPIKey,
httpClient: &http.Client{Timeout: 120 * time.Second},
}, nil
}
// authHeaders sets both auth headers on the outbound request:
// provisionAuthHeaders sets the auth headers for /cp/workspaces/* routes:
// - Authorization: Bearer <shared secret> — platform gate
// - X-Molecule-Admin-Token: <per-tenant token> — identity gate
//
@ -76,7 +86,7 @@ func NewCPProvisioner() (*CPProvisioner, error) {
// deployments without a real CP still work (those don't hit a CP that
// enforces either gate). In prod both are set by the controlplane
// bootstrap, so both headers land on every outbound call.
func (p *CPProvisioner) authHeaders(req *http.Request) {
func (p *CPProvisioner) provisionAuthHeaders(req *http.Request) {
if p.sharedSecret != "" {
req.Header.Set("Authorization", "Bearer "+p.sharedSecret)
}
@ -85,6 +95,23 @@ func (p *CPProvisioner) authHeaders(req *http.Request) {
}
}
// adminAuthHeaders sets the auth header for /cp/admin/* routes. The CP
// gates this route family with CP_ADMIN_API_TOKEN — a distinct secret
// from the provision-route shared secret so a compromised tenant can't
// read other tenants' serial console via /cp/admin/workspaces/:id/console.
//
// The per-tenant X-Molecule-Admin-Token is still included for parity
// with the provision path (CP may cross-check it for audit attribution
// even on admin calls).
func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
if p.cpAdminAPIKey != "" {
req.Header.Set("Authorization", "Bearer "+p.cpAdminAPIKey)
}
if p.adminToken != "" {
req.Header.Set("X-Molecule-Admin-Token", p.adminToken)
}
}
type cpProvisionRequest struct {
OrgID string `json:"org_id"`
WorkspaceID string `json:"workspace_id"`
@ -123,7 +150,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
return "", fmt.Errorf("cp provisioner: create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
p.authHeaders(httpReq)
p.provisionAuthHeaders(httpReq)
resp, err := p.httpClient.Do(httpReq)
if err != nil {
@ -158,7 +185,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
url := fmt.Sprintf("%s/cp/workspaces/%s?instance_id=%s", p.baseURL, workspaceID, workspaceID)
req, _ := http.NewRequestWithContext(ctx, "DELETE", url, nil)
p.authHeaders(req)
p.provisionAuthHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cp provisioner: stop: %w", err)
@ -194,7 +221,7 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool, error) {
url := fmt.Sprintf("%s/cp/workspaces/%s/status?instance_id=%s", p.baseURL, workspaceID, workspaceID)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
p.authHeaders(req)
p.provisionAuthHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return true, fmt.Errorf("cp provisioner: status: %w", err)
@ -226,7 +253,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
func (p *CPProvisioner) GetConsoleOutput(ctx context.Context, workspaceID string) (string, error) {
url := fmt.Sprintf("%s/cp/admin/workspaces/%s/console", p.baseURL, workspaceID)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
p.authHeaders(req)
p.adminAuthHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cp provisioner: console: %w", err)

View File

@ -40,13 +40,13 @@ func TestNewCPProvisioner_FallsBackToProvisionSharedSecret(t *testing.T) {
}
}
// TestAuthHeaders_NoopWhenBothEmpty — the self-hosted path that
// doesn't gate /cp/workspaces/* must not add stray auth headers
// TestProvisionAuthHeaders_NoopWhenBothEmpty — the self-hosted path
// that doesn't gate /cp/workspaces/* must not add stray auth headers
// (bearer-like content would surprise non-bearer intermediaries).
func TestAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
func TestProvisionAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
p := &CPProvisioner{sharedSecret: "", adminToken: ""}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeaders(req)
p.provisionAuthHeaders(req)
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization set to %q with empty secret; want unset", got)
}
@ -55,13 +55,13 @@ func TestAuthHeaders_NoopWhenBothEmpty(t *testing.T) {
}
}
// TestAuthHeaders_SetsBothWhenBothProvided — happy path for SaaS
// tenants. Both the platform-wide shared secret and the per-tenant
// TestProvisionAuthHeaders_SetsBothWhenBothProvided — happy path for
// SaaS tenants. Both the platform-wide shared secret and the per-tenant
// admin_token land on every outbound call.
func TestAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
func TestProvisionAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
p := &CPProvisioner{sharedSecret: "the-secret", adminToken: "tok-abc"}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeaders(req)
p.provisionAuthHeaders(req)
if got := req.Header.Get("Authorization"); got != "Bearer the-secret" {
t.Errorf("Authorization = %q, want %q", got, "Bearer the-secret")
}
@ -70,14 +70,14 @@ func TestAuthHeaders_SetsBothWhenBothProvided(t *testing.T) {
}
}
// TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty — in the transition
// window where the tenant has admin_token but PROVISION_SHARED_SECRET
// isn't set, still send the admin token. CP middleware decides whether
// the shared secret is required.
func TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
// TestProvisionAuthHeaders_OnlyAdminTokenWhenSecretEmpty — in the
// transition window where the tenant has admin_token but
// PROVISION_SHARED_SECRET isn't set, still send the admin token. CP
// middleware decides whether the shared secret is required.
func TestProvisionAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
p := &CPProvisioner{sharedSecret: "", adminToken: "tok-abc"}
req := httptest.NewRequest("GET", "http://x/", nil)
p.authHeaders(req)
p.provisionAuthHeaders(req)
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization = %q, want unset", got)
}
@ -86,6 +86,75 @@ func TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty(t *testing.T) {
}
}
// TestAdminAuthHeaders_UsesCPAdminAPIKeyNotSharedSecret — /cp/admin/*
// routes are gated by CP_ADMIN_API_TOKEN on the CP side (distinct from
// PROVISION_SHARED_SECRET). The tenant must send the admin key as the
// bearer on these routes or CP returns 401.
func TestAdminAuthHeaders_UsesCPAdminAPIKeyNotSharedSecret(t *testing.T) {
p := &CPProvisioner{
sharedSecret: "provision-secret",
adminToken: "tok-abc",
cpAdminAPIKey: "admin-api-key",
}
req := httptest.NewRequest("GET", "http://x/", nil)
p.adminAuthHeaders(req)
if got := req.Header.Get("Authorization"); got != "Bearer admin-api-key" {
t.Errorf("Authorization = %q, want %q", got, "Bearer admin-api-key")
}
if got := req.Header.Get("X-Molecule-Admin-Token"); got != "tok-abc" {
t.Errorf("X-Molecule-Admin-Token = %q, want tok-abc", got)
}
}
// TestAdminAuthHeaders_FallsBackToSharedSecretWhenAdminKeyUnset —
// self-hosted and dev deployments set PROVISION_SHARED_SECRET but not
// CP_ADMIN_API_TOKEN. Fall back so single-secret setups keep working
// (CP in those deployments either accepts both bearers or doesn't gate
// /cp/admin/*).
func TestAdminAuthHeaders_FallsBackToSharedSecretWhenAdminKeyUnset(t *testing.T) {
p := &CPProvisioner{
sharedSecret: "provision-secret",
adminToken: "tok-abc",
cpAdminAPIKey: "provision-secret", // NewCPProvisioner sets this when env is unset
}
req := httptest.NewRequest("GET", "http://x/", nil)
p.adminAuthHeaders(req)
if got := req.Header.Get("Authorization"); got != "Bearer provision-secret" {
t.Errorf("Authorization = %q, want fallback %q", got, "Bearer provision-secret")
}
}
// TestNewCPProvisioner_ReadsCPAdminAPIToken — env-to-field wiring.
// When CP_ADMIN_API_TOKEN is set, cpAdminAPIKey picks it up.
func TestNewCPProvisioner_ReadsCPAdminAPIToken(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "org-abc")
t.Setenv("MOLECULE_CP_SHARED_SECRET", "shared")
t.Setenv("CP_ADMIN_API_TOKEN", "admin-key")
p, err := NewCPProvisioner()
if err != nil {
t.Fatalf("NewCPProvisioner: %v", err)
}
if p.cpAdminAPIKey != "admin-key" {
t.Errorf("cpAdminAPIKey = %q, want %q", p.cpAdminAPIKey, "admin-key")
}
}
// TestNewCPProvisioner_CPAdminAPITokenFallsBackToSharedSecret —
// operators that don't split the two secrets (dev / self-hosted) still
// get a working admin bearer via the fallback.
func TestNewCPProvisioner_CPAdminAPITokenFallsBackToSharedSecret(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "org-abc")
t.Setenv("MOLECULE_CP_SHARED_SECRET", "shared")
t.Setenv("CP_ADMIN_API_TOKEN", "")
p, err := NewCPProvisioner()
if err != nil {
t.Fatalf("NewCPProvisioner: %v", err)
}
if p.cpAdminAPIKey != "shared" {
t.Errorf("cpAdminAPIKey fallback = %q, want %q", p.cpAdminAPIKey, "shared")
}
}
// TestStart_HappyPath — Start posts to the stubbed CP, passes the
// bearer, and parses the returned instance_id.
func TestStart_HappyPath(t *testing.T) {
@ -516,3 +585,46 @@ func TestClose_Noop(t *testing.T) {
t.Errorf("Close should return nil, got %v", err)
}
}
// TestGetConsoleOutput_UsesAdminBearer — regression guard for the
// split-bearer fix. /cp/admin/workspaces/:id/console must send
// Authorization: Bearer <cpAdminAPIKey>, NOT <sharedSecret>.
// Previously the tenant sent sharedSecret → CP 401 → tenant 502 on
// the "View Logs" UI. Symptom log: "cp provisioner: console: unexpected 401"
// on hongmingwang prod tenant, 2026-04-22.
func TestGetConsoleOutput_UsesAdminBearer(t *testing.T) {
var sawBearer, sawMethod, sawPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawBearer = r.Header.Get("Authorization")
sawMethod = r.Method
sawPath = r.URL.Path
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"output":"boot log"}`)
}))
defer srv.Close()
p := &CPProvisioner{
baseURL: srv.URL,
orgID: "org-1",
sharedSecret: "provision-secret-do-not-use-here",
adminToken: "tok-xyz",
cpAdminAPIKey: "admin-api-key",
httpClient: srv.Client(),
}
out, err := p.GetConsoleOutput(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetConsoleOutput: %v", err)
}
if out != "boot log" {
t.Errorf("output = %q, want %q", out, "boot log")
}
if sawMethod != "GET" {
t.Errorf("method = %q, want GET", sawMethod)
}
if sawPath != "/cp/admin/workspaces/ws-1/console" {
t.Errorf("path = %q, want /cp/admin/workspaces/ws-1/console", sawPath)
}
if sawBearer != "Bearer admin-api-key" {
t.Errorf("bearer = %q, want Bearer admin-api-key (NOT the provision secret)", sawBearer)
}
}