Search by

mattleads / agent-publishing-bundle

mattleads

Publishing API and MCP server for Symfony sites: AI agents, marketing platforms and webmasters draft, approve and publish articles and read view and referrer counts. No frontend.

Package info

github.com/mattleads/agent-publishing-bundle

Type:symfony-bundle

pkg:composer/mattleads/agent-publishing-bundle

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.1 2026-11-06 16:27 UTC

This package is auto-updated.

Last update: 2026-11-06 16:32:27 UTC


README

CI

A publishing API for Symfony sites. Install it and AI agents, marketing platforms and your own scripts can draft, submit, approve and publish articles through scoped API keys, in the sections and languages you declare in YAML. No frontend ships with this bundle: approval happens through the API, and your site keeps rendering articles the way it already does.

Status: 0.2, usable. REST, MCP, analytics, lint and SEO output are in; see DESIGN.md for the original plan.

Requirements

  • PHP 8.2+, Symfony 6.4 / 7.x / 8.x
  • Optional: Doctrine ORM for the bundled storage. Without it, implement ArticleStorageInterface and ApiKeyStorageInterface yourself.

Installation

composer require mattleads/agent-publishing-bundle
# config/packages/mattleads_agent_publishing.yaml
mattleads_agent_publishing:
    storage: doctrine             # doctrine | memory | custom
    locales: [en, de]
    default_locale: en
    sections:
        blog: { path: /blog }                 # all locales
        docs: { path: /docs, locales: [en] }
    api:
        prefix: /api/publisher/v1
# config/routes/mattleads_agent_publishing.yaml
mattleads_agent_publishing:
    resource: '@MattLeadsAgentPublishingBundle/config/routes.php'
# config/packages/security.yaml
security:
    firewalls:
        agent_publishing:
            pattern: ^/api/publisher
            stateless: true
            provider: app_users          # any provider; API keys carry their own identity
            entry_point: MattLeads\AgentPublishingBundle\Security\ApiKeyAuthenticator
            custom_authenticators:
                - MattLeads\AgentPublishingBundle\Security\ApiKeyAuthenticator
    access_control:
        - { path: ^/api/publisher/v1/openapi\.json$, roles: PUBLIC_ACCESS }
        - { path: ^/api/publisher, roles: ROLE_AGENT_PUBLISHING }

The entry point answers requests without a key with a JSON 401. Each scope also maps to a role (articles:draft to ROLE_AGENT_PUBLISHING_ARTICLES_DRAFT) if you prefer access_control rules per path.

With storage: doctrine, run a migration: the bundle maps agent_publishing_article, agent_publishing_api_key, agent_publishing_view_stat and agent_publishing_referrer_stat.

Issue a key

php bin/console agent-publishing:api-key:create marketcells --scope=articles:draft --scope=articles:read --section=blog

The secret is printed once. Scopes:

Scope Allows
articles:read list and read articles in any state
articles:draft create and update drafts, submit for review
articles:approve approve or reject articles in review
articles:publish publish directly or on a schedule, archive
stats:read view counts and referrers
keys:manage list, issue and revoke keys within your own scopes and sections

A key with no --section can write to every section. --expires="+90 days" sets an expiry.

php bin/console agent-publishing:api-key:list                 # ids, names, scopes, sections, expiry, last use; never secrets
php bin/console agent-publishing:api-key:revoke seo-agent     # by name (when unique) or by id

Keys over the API

With keys:manage, a platform can issue keys for its own agents without shell access:

GET    /api/publisher/v1/keys
POST   /api/publisher/v1/keys        { name, scopes, sections?, expires_at? }   secret returned once
GET    /api/publisher/v1/keys/{id}
DELETE /api/publisher/v1/keys/{id}   revoke

A key can only grant a subset of its own scopes and sections, and a section-restricted key only sees and revokes keys inside its sections. Secrets and hashes are never returned by the read endpoints.

To keep key issuance on the command line only, switch the endpoints off; they answer 404 and leave the OpenAPI document:

mattleads_agent_publishing:
    api:
        key_management: false

Use the API

The OpenAPI 3.1 document is served at GET /api/publisher/v1/openapi.json. Point a client generator or an agent at it; every operation names the scope it needs in x-scope.

GET    /api/publisher/v1/openapi.json
GET    /api/publisher/v1/articles?section=blog&locale=en&state=draft&page=1&per_page=20&sort=updated|published
GET    /api/publisher/v1/authors
POST   /api/publisher/v1/articles                { section, locale, title, body, format?, slug?, summary?, tags?, translation_group?,
                                                   meta_title?, meta_description?, cover_image?, cover_image_alt?, author? }
GET    /api/publisher/v1/articles/{id}
PATCH  /api/publisher/v1/articles/{id}
POST   /api/publisher/v1/articles/{id}/submit
POST   /api/publisher/v1/articles/{id}/approve
POST   /api/publisher/v1/articles/{id}/reject    { reason }
POST   /api/publisher/v1/articles/{id}/publish   { publish_at? }
POST   /api/publisher/v1/articles/{id}/archive
POST   /api/publisher/v1/articles/{id}/lint
POST   /api/publisher/v1/lint                     { same body as create; nothing is stored }
GET    /api/publisher/v1/articles/{id}/stats?from=&to=
GET    /api/publisher/v1/stats/summary?section=&from=&to=
GET    /api/publisher/v1/stats/referrers?section=&from=&to=&limit=

Send the key as Authorization: Bearer apb_.... Errors are JSON with error and, for validation, violations.

Workflow

draft → in_review (submit) → published (approve) or back to draft (reject). A key with articles:publish can publish from draft or in_review directly, or schedule with publish_at, which moves the article to scheduled. archive ends the life of a published or scheduled article. Every transition dispatches MattLeads\AgentPublishingBundle\Workflow\ArticleTransitioned.

Editing live articles and slug redirects

By default only drafts and articles in review can be edited. To fix a published or scheduled article in place:

mattleads_agent_publishing:
    editing: { published: true }

Editing a live article then needs articles:publish (a drafting key never changes what readers see). When its slug changes, the old slug is kept as a former slug: ArticleStorageInterface::findByFormerSlug() returns the article so your controller can answer with a permanent redirect to its current URL, and the API returns former_slugs. Every former slug points at the article, not at the next slug, so a redirect is always one hop. A former slug is forgotten as soon as any article takes it again.

#[Route('/blog/{slug}', name: 'blog_show', defaults: ['_agent_publishing_section' => 'blog'])]
public function show(string $slug): Response
{
    $article = $this->articles->findBySlug('blog', 'en', $slug);
    if (null === $article && null !== $moved = $this->articles->findByFormerSlug('blog', 'en', $slug)) {
        if (ArticleState::Published === $moved->getState()) {
            return $this->redirect($this->urls->urlFor($moved), Response::HTTP_MOVED_PERMANENTLY);
        }
    }
    // ...
}

Authors

mattleads_agent_publishing:
    authors:
        jane:
            name: Jane Doe
            url: https://example.com/about
            image: https://example.com/jane.jpg
            bio: Writes about Symfony in production.
            same_as: [https://github.com/janedoe, https://www.linkedin.com/in/janedoe]
    seo:
        default_author: organization          # or an author key

Agents read the list with GET /authors or the list_authors tool and pass a key as author; an unknown key is refused. With no author, the publisher organisation is credited (or default_author). agent_publishing_author(article) returns the credited person as an array for your byline, or null.

Scheduled publishing

Something has to publish scheduled articles when their time comes. Pick one:

# cron, every minute
* * * * * php /path/to/app/bin/console agent-publishing:publish-scheduled
# or Symfony Scheduler (composer require symfony/scheduler, then run messenger:consume scheduler_default)
mattleads_agent_publishing:
    scheduler: { enabled: true, frequency: '1 minute', schedule: default }

The transition is recorded with actor scheduler in the ArticleTransitioned event.

MCP: the same operations as tools for agents

POST /api/publisher/v1/mcp is a Model Context Protocol endpoint, tools only, JSON-RPC 2.0 over HTTP (Streamable HTTP transport), with no protocol dependency in the bundle. Agents authenticate with the same bearer keys and see only the tools their scopes allow. Point Claude Code at it:

claude mcp add --transport http publisher https://example.com/api/publisher/v1/mcp --header "Authorization: Bearer apb_..."

Tools: list_sections, get_style_guide, list_articles, get_article, lint_draft, lint_article, create_draft, update_draft, submit_article, approve_article, reject_article, publish_article, archive_article, get_article_stats, get_stats_summary, get_top_referrers. Key management stays REST-only.

mattleads_agent_publishing:
    mcp:
        enabled: true
        path: /mcp                                   # appended to api.prefix
        instructions: >
            This is the engineering blog of Example Ltd. Draft in the "blog" section, lint until the
            report passes, then submit. A human approves. Never publish directly.
        style_guide: |
            Voice: senior engineer briefing a peer. Lead with the answer. Name exact versions.
            Avoid: "game-changer", "in today's fast-paced world", emoji.

instructions is sent to the agent at initialize; style_guide is what get_style_guide returns. Add your own tool by implementing McpToolInterface and tagging the service mattleads_agent_publishing.mcp_tool.

Lint: let the agent fix the article before a human sees it

POST /articles/{id}/lint checks a stored article, POST /lint checks a payload you have not created yet. Both return one entry per rule with pass, warn or fail, a message saying what was found and a hint saying how to fix it. An agent loops on the report until the status is pass, then submits.

Rule Checks
title_length 30-60 characters; fail above 70
summary present, 70-160 characters, not a copy of the title (it is the meta description)
opening_paragraph the body starts with a 40-120 word paragraph that uses the title's terms, which is what answer engines quote
headings no H1 in the body, no skipped levels, at least two H2 above 500 words
word_count at least min_words for the section
readability Flesch reading ease for English, warns below 30
images_alt every image has alt text
links descriptive link text, at least min_internal_links internal links
tags between 1 and 8
slug unique in the section and locale, under 75 characters, shares a term with the title
faq a FAQ heading, only for sections with require_faq: true

Per-section expectations live in configuration:

mattleads_agent_publishing:
    sections:
        docs: { path: /docs, lint: { require_faq: true, min_words: 500, min_internal_links: 2 } }

Add your own rule by implementing LintRuleInterface and tagging the service mattleads_agent_publishing.lint_rule.

SEO output: JSON-LD, sitemap and llms.txt

The bundle needs to know your article URLs. By default it builds them from a pattern; set url_resolver to a service implementing UrlResolverInterface when your routing differs.

mattleads_agent_publishing:
    seo:
        base_url: https://example.com          # defaults to the current request's scheme and host
        url_pattern: '{base}{locale_prefix}{section_path}/{slug}'   # {locale_prefix} is "" for the default locale, "/de" otherwise; also {locale} and {section}
        site_name: Example Engineering Blog
        site_description: Articles on Symfony, PHP and running services in production.
        publisher: { name: Example Ltd, url: https://example.com, logo: https://example.com/logo.png }
        author: { name: Jane Doe, url: https://example.com/about }
        jsonld_type: TechArticle               # Article | BlogPosting | NewsArticle | TechArticle
        sitemap: { enabled: true, path: /sitemap-publisher.xml }
        llms_txt: { enabled: true, path: /llms.txt, per_section: 50 }

JSON-LD. In the template that renders an article:

{{ agent_publishing_jsonld(article) }}
{{ agent_publishing_jsonld(article, { image: asset('cover.png') }) }}   {# second argument overrides or adds keys #}

It emits a <script type="application/ld+json"> with headline, description (meta_description, else summary), image (cover_image), dates, language, section, keywords, author and publisher. The author is the article's configured person with url, image and sameAs; otherwise the legacy seo.author; otherwise the publisher organisation. Without Twig, call JsonLdBuilder::script() or build() yourself.

Sitemap at /sitemap-publisher.xml lists every published article with lastmod, and hreflang alternates for articles that share a translation_group, including x-default for the default locale. Reference it from your main sitemap index or robots.txt.

llms.txt at /llms.txt follows llmstxt.org: the site name and description, then a Markdown list per section of recent published articles with their summaries, so language models know what the site offers.

Both are public and sit outside the API prefix. Keep them out of your API firewall and access_control.

Analytics

The bundle counts views per article and per referrer, by day. It stores counts only: no cookies, no IP addresses, no user agents, no raw URLs. Each view is bucketed into a kind:

Kind Meaning
direct no referrer
internal a link from your own site
search Google, Bing, DuckDuckGo and other engines
social X, LinkedIn, Reddit, Hacker News, Telegram and friends
ai_referral a human sent by ChatGPT, Perplexity, Claude, Gemini or another assistant
ai_crawler GPTBot, ClaudeBot, PerplexityBot, Google-Extended and other AI crawlers
bot everything else that is not a browser
referral any other site

views counts human kinds. crawler_views counts ai_crawler and bot, so you can see which articles AI systems read without inflating the human numbers.

Tell the bundle which page is an article

Add a route default to the route that renders an article. The listener runs after the response is sent, finds the published article by section, locale and slug, and counts the view:

#[Route('/blog/{slug}', name: 'blog_show', defaults: ['_agent_publishing_section' => 'blog'])]

The slug comes from the slug attribute and the locale from _locale, falling back to default_locale. Both names are configurable under analytics. If your URLs do not carry a slug, call the recorder yourself:

public function __construct(private ViewRecorder $views) {}

$this->views->record($article, $request);

With symfony/messenger installed, each view is dispatched as an ArticleViewed message and counted by its handler. Route it to an async transport to keep writes off the request:

framework:
    messenger:
        routing:
            MattLeads\AgentPublishingBundle\Analytics\ArticleViewed: async

Without Messenger, the view is counted synchronously after the response has been sent.

mattleads_agent_publishing:
    analytics:
        enabled: true
        timezone: Europe/London     # defines a "day"
        site_host: example.com      # links from here count as internal; defaults to the request host

Security notes

  • Keys are 256-bit random secrets, shown once and stored as an HMAC-SHA256 keyed with kernel.secret. Changing APP_SECRET invalidates every key, which is also how you rotate them all at once. An empty APP_SECRET is refused.
  • Scopes and sections are checked on every endpoint. A key restricted to sections gets 404, not 403, for articles outside them, and listings and totals are limited to its sections.
  • Rate limiting is not built in. Put the API behind symfony/rate-limiter or your gateway; a brute-force on 256-bit secrets is not the concern, write amplification is.
  • HTML bodies (format: html) are stored as sent. If you render them, sanitize first, for example with symfony/html-sanitizer; an agent key with articles:draft must not be able to inject script into your pages even before a human approves.
  • Public URLs in JSON-LD, the sitemap and llms.txt use seo.base_url when set, else the request host. Set base_url in production, or at least framework.trusted_hosts, so a forged Host header cannot plant another domain in cached output.
  • Analytics stores counts only, keyed by referrer host and visit kind. Referrer spam creates rows; route ArticleViewed to an async transport and prune old rollups if it becomes a problem.
  • Expiry and revocation. Keys can carry expires_at; revoke through DELETE /keys/{id}, the manager, or by setting revoked_at. Revoked and expired keys answer 401 on the next request.

Bring your own storage

mattleads_agent_publishing:
    storage: custom
    article_storage: App\Publishing\ArticleStorage     # implements ArticleStorageInterface
    api_key_storage: App\Publishing\ApiKeyStorage      # implements ApiKeyStorageInterface
    stats_storage: App\Publishing\StatsStorage         # implements StatsStorageInterface

Your ArticleStorageInterface returns objects implementing ArticleInterface, which is read-only. All mutation goes through the storage, so the bundle never touches your entities directly. Since 0.3 the storage also keeps former slugs (findByFormerSlug(), formerSlugs()) and honours ArticleFilter::$sort.

License

MIT