diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index e2af87f..2f74ea8 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -60,7 +60,7 @@ export { marketplaceEnv } from "./skill-market/ingest/env.js";
// search + the enumeration seam
export { searchSkills, listUnlockable } from "./search/index.js";
export type { SearchFilters, SortBy, SearchOptions, UnlockableWorkflow, UnlockOptions } from "./search/index.js";
-export { dasSource, indexerSource, ownedSkills } from "./core/skillSource.js";
+export { dasSource, indexerSource, ownedSkills, workflowMintsAmong } from "./core/skillSource.js";
export type { SkillSource } from "./core/skillSource.js";
// reputation (derived live from supply + reviews)
export { getReputation, getLeaderboard } from "./reputation/index.js";
diff --git a/surfaces/localhost/src/index.ts b/surfaces/localhost/src/index.ts
index 7fbda02..8a8d525 100644
--- a/surfaces/localhost/src/index.ts
+++ b/surfaces/localhost/src/index.ts
@@ -68,6 +68,7 @@ import {
maskedGithubToken,
loadGithubToken,
registerVerifiedWork,
+ workflowMintsAmong,
} from "@iqlabs-official/agent-sdk";
const PORT = Number(process.env.AGENTNET_PORT ?? 4317);
@@ -470,7 +471,13 @@ function attachMarketHandlers(c: Client) {
]);
const names = cards.map((card) => card.name);
const mints = Object.fromEntries(cards.map((card) => [card.name, card.id]));
- c.send({ type: "ownedSkills", names, mints, disposedMints, cards });
+ // Ground-truth which owned mints are workflows (not `card.type`: a mint missing from
+ // the indexer catalog falls back to type "skill" even when it's actually a workflow —
+ // see ownedSkillCards). The workflow-publish picker needs this to keep owned workflows
+ // out of the required-skills checklist (a workflow can't require another workflow).
+ const allMints = [...Object.values(mints), ...Object.values(disposedMints)];
+ const workflowMints = allMints.length ? await workflowMintsAmong(allMints).catch(() => [] as string[]) : [];
+ c.send({ type: "ownedSkills", names, mints, disposedMints, cards, workflowMints });
}
// `quiet` = expected transient (no wallet yet): answer reads with empty results and
// skip every toast, so the market just shows clean empty states until the wallet lands.
diff --git a/surfaces/webview/src/market/MarketScreen.tsx b/surfaces/webview/src/market/MarketScreen.tsx
index 48e7484..384104b 100644
--- a/surfaces/webview/src/market/MarketScreen.tsx
+++ b/surfaces/webview/src/market/MarketScreen.tsx
@@ -131,11 +131,12 @@ export function MarketScreen({ tab }: { tab: ShellTab }) {
);
}
- // Publish form (market tab)
+ // Publish form (market tab) — opens pre-set to whichever kind you were browsing (skill
+ // or workflow tab), same as the VSCode builder.
if (view === "publish") {
return (
-
setView("browse")} />
+ setView("browse")} />
);
}
diff --git a/surfaces/webview/src/market/PublishForm.tsx b/surfaces/webview/src/market/PublishForm.tsx
index 04767e9..f79f299 100644
--- a/surfaces/webview/src/market/PublishForm.tsx
+++ b/surfaces/webview/src/market/PublishForm.tsx
@@ -63,10 +63,18 @@ function PublishProgressView({ progress }: { progress: { phase: "store" | "mint"
interface Props {
onBack: () => void;
+ // Which kind the form opens to — e.g. hitting Publish from the Workflow browse tab should
+ // land straight in workflow mode, same as the VSCode builder. Defaults to "skill".
+ initialKind?: "skill" | "workflow";
}
-export function PublishForm({ onBack }: Props) {
+// A workflow can require at most 16 skills (MAX_REQUIRED_SKILLS in the agent-workflow-nft
+// contract) and at least 1 — the on-chain gate that gives a workflow meaning.
+const MAX_REQUIRED_SKILLS = 16;
+
+export function PublishForm({ onBack, initialKind = "skill" }: Props) {
const { send, state, clearPublishResult } = useStore();
+ const [kind, setKind] = useState<"skill" | "workflow">(initialKind);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [text, setText] = useState("");
@@ -75,27 +83,59 @@ export function PublishForm({ onBack }: Props) {
const [priceSol, setPriceSol] = useState("0");
const [imageUrl, setImageUrl] = useState(""); // http/on-chain link only — no file upload
const [submitting, setSubmitting] = useState(false);
+ // Required-skills picker (workflow mode only): mint -> selected.
+ const [reqSel, setReqSel] = useState>({});
+ const t = PUBLISH_THEME[kind];
const result = state.publishResult;
+ // Skills the wallet actually owns and can require: must have a resolvable mint, and must
+ // NOT itself be a workflow (the on-chain gate rejects a workflow as a required_skill).
+ const ownedSkillNames = state.marketOwned.filter((n) => {
+ const mint = state.marketOwnedMints[n];
+ return !!mint && !state.marketOwnedWorkflowMints.includes(mint);
+ });
+ const chosenReqMints = Object.keys(reqSel).filter((m) => reqSel[m]);
+
// A result (success OR failure) ends the in-flight state so the form/button come back.
useEffect(() => { if (result) setSubmitting(false); }, [result]);
function handleSubmit() {
- if (!name.trim() || !text.trim()) return;
+ if (!name.trim()) return;
+ if (kind === "skill" && !text.trim()) return;
+ if (kind === "workflow" && (chosenReqMints.length === 0 || chosenReqMints.length > MAX_REQUIRED_SKILLS)) return;
setSubmitting(true);
clearPublishResult();
+ // Workflow mode: synthesize the SKILL.md frontmatter (type: workflow + requiredSkills) so
+ // the current backend's frontmatter sniff (env.ts) mints it as a workflow — same trick the
+ // VSCode webview builder uses. No SKILL.md body to author; the workflow IS its required skills.
+ const body = kind === "workflow"
+ ? [
+ "---",
+ `name: ${name.trim()}`,
+ `description: ${description.trim().replace(/\s*\n\s*/g, " ")}`,
+ "type: workflow",
+ `requiredSkills: [${chosenReqMints.join(", ")}]`,
+ "---",
+ "",
+ `# ${name.trim()}`,
+ "",
+ description.trim(),
+ "",
+ ].join("\n")
+ : text.trim();
// Image is a link ONLY (http URL or on-chain address). The app can't attach/upload a
// file, and a link stays tiny so the body doesn't chunk on-chain (keeps signatures low).
send({
type: "publishSkill",
name: name.trim(),
description: description.trim(),
- text: text.trim(),
+ text: body,
category: category.trim() || undefined,
hashtags: hashtags.split(",").map((h) => h.trim()).filter(Boolean),
priceSol: priceSol || "0",
image: imageUrl.trim() || undefined,
+ ...(kind === "workflow" ? { kind: "workflow" as const, requiredSkills: chosenReqMints } : {}),
});
setTimeout(() => setSubmitting(false), 15000);
}
@@ -105,13 +145,13 @@ export function PublishForm({ onBack }: Props) {
if (result?.ok) {
return (