AR: Agent Registry & Marketplace - Publish and Fork#215
Open
Conversation
samxu01
requested changes
Apr 18, 2026
Contributor
samxu01
left a comment
There was a problem hiding this comment.
Structurally sound RFC — good precedents cited, explicit failure matrix, and clear open questions. Requesting changes because several concrete claims and field mappings conflict with the existing Installable / AgentRegistry schemas and would block implementation as written.
P0 (blockers):
- §5.3 namespace regex doesn't match either existing schema's field constraints — first publish would fail Mongoose validation.
- §6c.2 asserts
AgentRegistry.agentNameaccepts arbitrary strings; the schema's regex explicitly rejects@and/. - §6c.3 maps unpublish to AR
status: 'deprecated'when'unpublished'is already a valid enum value. - §6b / §6c.3: install route changes are understated — a status guard plus (probably) input-validation relaxation are required, not zero changes.
P1 (worth tightening before coding):
- "Latest" version resolution and
Installable.versionscalar update rules undefined. - NFR-2 "identical content" needs a concrete hashing spec.
stats.activeInstalls === 0hard-delete guard races against new installs.- §6c.4 field map skips the hardest three cases (runtime, persona.systemPrompt, categories shape).
- Fork request
versionsemantics ambiguous (forks-from-old-version vs. initial-version-of-fork). - Fork
source: 'marketplace'vs ADR-001'template'unreconciled. - HTTP 207 is unconventional; prefer 201 + warnings payload.
Strengths worth keeping: write-ordering rationale in §6c.1, lifecycle sync table in §6c.3, server-enforced namespace ownership, and identity-continuity callouts.
Generated by Claude Code
| /** | ||
| * POST /api/marketplace/publish | ||
| */ | ||
| router.post('/publish', auth, async (req: any, res: any) => { |
Comment on lines
+100
to
+285
| router.post('/publish', auth, async (req: any, res: any) => { | ||
| try { | ||
| const userId = req.userId || req.user?.id; | ||
| const username = await resolveUsername(req); | ||
| if (!userId || !username) { | ||
| return res.status(401).json({ error: 'Unauthorized' }); | ||
| } | ||
|
|
||
| const { | ||
| installableId, name, description, version, kind, scope, | ||
| requires, components, readme, categories, tags, | ||
| } = req.body; | ||
|
|
||
| if (!installableId || !name || !version || !kind) { | ||
| return res.status(400).json({ error: 'installableId, name, version, and kind are required' }); | ||
| } | ||
|
|
||
| if (installableId.length > 64) { | ||
| return res.status(400).json({ error: 'installableId must be 64 characters or fewer' }); | ||
| } | ||
| if (name.length > 100) { | ||
| return res.status(400).json({ error: 'name must be 100 characters or fewer' }); | ||
| } | ||
| if (description && description.length > 500) { | ||
| return res.status(400).json({ error: 'description must be 500 characters or fewer' }); | ||
| } | ||
| if (readme && readme.length > 50000) { | ||
| return res.status(400).json({ error: 'readme must be 50,000 characters or fewer' }); | ||
| } | ||
| if (!['agent', 'app', 'skill', 'bundle'].includes(kind)) { | ||
| return res.status(400).json({ error: 'kind must be one of: agent, app, skill, bundle' }); | ||
| } | ||
| if (components && components.length > 50) { | ||
| return res.status(400).json({ error: 'Maximum 50 components per manifest' }); | ||
| } | ||
|
|
||
| const nsError = validateNamespace(installableId.toLowerCase(), username); | ||
| if (nsError) { | ||
| return res.status(400).json({ error: nsError }); | ||
| } | ||
|
|
||
| if (scope === 'instance') { | ||
| return res.status(400).json({ error: 'scope "instance" is reserved for admin/builtin manifests' }); | ||
| } | ||
|
|
||
| const existing = await Installable.findOne({ installableId: installableId.toLowerCase() }); | ||
|
|
||
| if (existing) { | ||
| if (existing.publisher?.userId?.toString() !== userId.toString()) { | ||
| return res.status(403).json({ error: 'Not authorized to update this manifest' }); | ||
| } | ||
|
|
||
| const existingVersion = (existing.versions || []).find( | ||
| (v: any) => v.version === version, | ||
| ); | ||
| if (existingVersion) { | ||
| return res.status(200).json({ | ||
| success: true, | ||
| manifest: { | ||
| installableId: existing.installableId, | ||
| version, | ||
| status: existing.status, | ||
| isNew: false, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| existing.versions = [ | ||
| ...(existing.versions || []), | ||
| { version, publishedAt: new Date() }, | ||
| ]; | ||
| existing.version = version; | ||
| existing.name = name; | ||
| existing.description = description || existing.description; | ||
| if (components) existing.components = components; | ||
| if (requires) existing.requires = requires; | ||
| if (readme !== undefined) existing.readme = readme; | ||
| if (categories || tags) { | ||
| existing.marketplace = { | ||
| ...existing.marketplace?.toObject?.() || existing.marketplace || {}, | ||
| published: true, | ||
| category: categories?.[0] || existing.marketplace?.category || '', | ||
| tags: tags || existing.marketplace?.tags || [], | ||
| }; | ||
| } | ||
|
|
||
| // Write AR first (load-bearing table), then Installable — same | ||
| // ordering rationale as new-manifest path (§6c.1). | ||
| try { | ||
| await syncToAgentRegistry(existing, 'publish'); | ||
| } catch (arError) { | ||
| console.warn('[marketplace] AgentRegistry sync failed on update:', (arError as any).message); | ||
| return res.status(201).json({ | ||
| success: true, | ||
| warnings: ['AgentRegistry sync failed; retrying publish will fix.'], | ||
| manifest: { | ||
| installableId: existing.installableId, | ||
| version, | ||
| status: existing.status, | ||
| isNew: false, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| await existing.save(); | ||
|
|
||
| return res.json({ | ||
| success: true, | ||
| manifest: { | ||
| installableId: existing.installableId, | ||
| version, | ||
| status: existing.status, | ||
| isNew: false, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // New manifest: write to AgentRegistry first (load-bearing table), then Installable | ||
| const installableDoc = { | ||
| installableId: installableId.toLowerCase(), | ||
| name, | ||
| description: description || '', | ||
| version, | ||
| kind, | ||
| source: 'marketplace' as const, | ||
| scope: scope || 'pod', | ||
| requires: requires || [], | ||
| components: components || [], | ||
| readme, | ||
| marketplace: { | ||
| published: true, | ||
| category: categories?.[0] || '', | ||
| tags: tags || [], | ||
| verified: false, | ||
| rating: 0, | ||
| ratingCount: 0, | ||
| installCount: 0, | ||
| }, | ||
| publisher: { userId, name: username }, | ||
| status: 'active' as const, | ||
| versions: [{ version, publishedAt: new Date() }], | ||
| stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 }, | ||
| }; | ||
|
|
||
| // Write AR first | ||
| try { | ||
| await syncToAgentRegistry(installableDoc, 'publish'); | ||
| } catch (arError) { | ||
| console.error('[marketplace] AgentRegistry write failed:', (arError as any).message); | ||
| return res.status(500).json({ error: 'Failed to publish manifest' }); | ||
| } | ||
|
|
||
| // Then Installable | ||
| let created; | ||
| try { | ||
| created = await Installable.create(installableDoc); | ||
| } catch (installableError) { | ||
| console.warn('[marketplace] Installable write failed (AR succeeded):', (installableError as any).message); | ||
| return res.status(201).json({ | ||
| success: true, | ||
| warnings: ['Installable catalog write failed; manifest is installable but not yet browsable. Retry publish to sync.'], | ||
| manifest: { | ||
| installableId: installableDoc.installableId, | ||
| version, | ||
| status: 'active', | ||
| isNew: true, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| console.log(`[marketplace] action=publish user=${userId} manifest=${created.installableId} version=${version}`); | ||
|
|
||
| res.status(201).json({ | ||
| success: true, | ||
| manifest: { | ||
| installableId: created.installableId, | ||
| version: created.version, | ||
| status: created.status, | ||
| isNew: true, | ||
| }, | ||
| }); | ||
| } catch (error) { | ||
| console.error('[marketplace] publish error:', error); | ||
| res.status(500).json({ error: (error as any).message || 'Failed to publish' }); | ||
| } | ||
| }); |
| * DELETE /api/marketplace/publish/:installableId | ||
| * Soft-delete (unpublish) | ||
| */ | ||
| router.delete('/publish/:installableId(*)', auth, async (req: any, res: any) => { |
Comment on lines
+291
to
+316
| router.delete('/publish/:installableId(*)', auth, async (req: any, res: any) => { | ||
| try { | ||
| const userId = req.userId || req.user?.id; | ||
| const { installableId } = req.params; | ||
|
|
||
| const doc = await Installable.findOne({ installableId: installableId.toLowerCase() }); | ||
| if (!doc) return res.status(404).json({ error: 'Manifest not found' }); | ||
| if (doc.publisher?.userId?.toString() !== userId.toString()) { | ||
| return res.status(403).json({ error: 'Not authorized' }); | ||
| } | ||
|
|
||
| doc.status = 'unpublished'; | ||
| if (doc.marketplace) doc.marketplace.published = false; | ||
| await doc.save(); | ||
|
|
||
| try { await syncToAgentRegistry(doc, 'unpublish'); } catch (e) { | ||
| console.warn('[marketplace] AR sync failed on unpublish:', (e as any).message); | ||
| } | ||
|
|
||
| console.log(`[marketplace] action=unpublish user=${userId} manifest=${installableId}`); | ||
| res.json({ success: true, status: 'unpublished' }); | ||
| } catch (error) { | ||
| console.error('[marketplace] unpublish error:', error); | ||
| res.status(500).json({ error: 'Failed to unpublish' }); | ||
| } | ||
| }); |
| * DELETE /api/marketplace/manifests/:installableId | ||
| * Hard delete (only if 0 active installs) | ||
| */ | ||
| router.delete('/manifests/:installableId(*)', auth, async (req: any, res: any) => { |
| const sortObj = sortMap[sort] || sortMap.installs; | ||
|
|
||
| const [items, total] = await Promise.all([ | ||
| Installable.find(filter) |
| .skip(skip) | ||
| .limit(limitNum) | ||
| .lean(), | ||
| Installable.countDocuments(filter), |
Comment on lines
+570
to
+580
| router.get('/manifests/:installableId(*)', async (req: any, res: any) => { | ||
| try { | ||
| const { installableId } = req.params; | ||
| const doc = await Installable.findOne({ installableId: installableId.toLowerCase() }).lean(); | ||
| if (!doc) return res.status(404).json({ error: 'Manifest not found' }); | ||
| res.json(doc); | ||
| } catch (error) { | ||
| console.error('[marketplace] detail error:', error); | ||
| res.status(500).json({ error: 'Failed to get manifest' }); | ||
| } | ||
| }); |
| /** | ||
| * GET /api/marketplace/mine | ||
| */ | ||
| router.get('/mine', auth, async (req: any, res: any) => { |
Comment on lines
+585
to
+599
| router.get('/mine', auth, async (req: any, res: any) => { | ||
| try { | ||
| const userId = req.userId || req.user?.id; | ||
| if (!userId) return res.status(401).json({ error: 'Unauthorized' }); | ||
|
|
||
| const items = await Installable.find({ 'publisher.userId': userId }) | ||
| .sort({ updatedAt: -1 }) | ||
| .lean(); | ||
|
|
||
| res.json({ items, total: items.length }); | ||
| } catch (error) { | ||
| console.error('[marketplace] mine error:', error); | ||
| res.status(500).json({ error: 'Failed to list your manifests' }); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Self-serve publish & fork for the Commonly agent marketplace. Users can publish agent manifests under their own namespace (
@username/name), version them, fork published manifests, and manage lifecycle (unpublish/delete/deprecate). This is effectively ADR-001 Phase 2 for marketplace operations — the first dual-write path betweenInstallable(canonical) andAgentRegistry(compat shim).Design doc:
docs/design/marketplace-publish-fork.mdChanges
New file:
backend/routes/marketplace-api.ts(+603 lines)9 endpoints under
/api/marketplace:/publish/publish/:id/manifests/:id/fork/publish/:id/deprecate/browse/manifests/:id/manifests/:id/forks/mineKey implementation details:
syncToAgentRegistry(installable, action)maps Installable fields to AgentRegistry on every write. Write order: AR first (load-bearing), then Installable. Failure matrix from §6c.1 implemented.@scopemust matchreq.user.username(server-enforced). Bare names reserved for builtins.RUNTIME_MAP.(*)param from consuming/forks.req.user = { id }(no username), soresolveUsername()falls back to a DB lookup.Schema changes:
backend/models/Installable.tsinstallableIdregex relaxed:/^[a-z0-9-]+(\/[a-z0-9-]+)?$/→/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/forkedFrom?: { installableId, version, forkedAt }(fork lineage)readme?: string(long-form description for detail page)stats.forkCount(default 0)forkedFrom.installableId, text search onname + description + marketplace.tagsSchema changes:
backend/models/AgentRegistry.tsagentNameregex relaxed:/^[a-z0-9-]+$/→/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/Install route:
backend/routes/registry/install.tsagentNameregex to match the schema changeagent.status === 'unpublished'(HTTP 410)Server wiring:
backend/server.tsmarketplace-apirouter at/api/marketplace(alongside existingmarketplacerouter — no path conflicts)Test fix:
backend/__tests__/unit/models/installable.test.tsinstallableIdvalues from oldscope/nameformat to new format (bare-namefor builtins,@scope/namefor marketplace)Validation
Input validation (§6e)
installableId@username/namefor user manifests, max 64 charsnamedescriptionversionkindscopeinstancerejected for user manifestsreadmecomponents@scopemust match authed user's usernameManual API testing (local Docker Compose)
All 9 endpoints tested end-to-end against real MongoDB:
@testuser/my-agentv1.0.0)@testuser/my-agent-fork)Automated tests
Key suites:
installable.test.ts— 12/12 pass (updated installableId formats for new regex)self-serve-install.test.js— 5/5 pass (ADR-006 flow unaffected)marketplace.official.test.js— 1/1 pass (existing browse unaffected)registry.runtime-tokens.test.js— 6/6 pass (token flow unaffected)Regression checks
GET /api/marketplace/officialendpoint: unaffected (separate router, no path conflicts)POST /api/registry/installflow: works — dual-write ensures AR has the data, status guard added for unpublished manifestsArchitecture notes
source: 'marketplace'for forks? ADR-001'stemplatesource means admin-cloned templates, not user-to-user forks. Both source and fork are published catalog entries.Follow-up work (not in scope)
🤖 Generated with Claude Code