EmDash 1.0.1 Review: What Held Up Under Testing

I did not stop at publishing a test page in EmDash. I imported real articles, worked from my phone, tested agent permissions, and killed a transfer process to find out what held up.

A Real CMS, With a Developer’s Responsibilities

EmDash is a CMS that runs inside an Astro application. The public pages and admin share its runtime and database, while Astro components control the public HTML. It is not a separate headless service that I connected to an unrelated frontend.

It caught my attention because it treats AI agents as clients of the publishing system, not just consumers of a writing button. That was worth investigating, but an agent interface is not enough to make a CMS useful. I wanted to know whether I could publish with it, change its presentation, control its extensions, move its data, and recover when something went wrong.

My verdict after testing version 1.0.1 is that EmDash deserves a serious look from developers who want structured publishing inside an Astro application. The editorial workflow held up, the tested permission boundaries refused unauthorized operations, and an interrupted import recovered to the same verified result as an uninterrupted transfer. The costs are real too: source-level theme work, operating the Node service and optional workerd sandbox, uneven interface contracts, migration cleanup, and recovery procedures that demand more than downloading a backup file.

This review covers testing completed on October 1, 2026, using Node.js, SQLite, local media storage, nginx, and systemd on a VPS. Sandboxed plugin tests used the Node/workerd runner. I used AI-assisted tooling for source inspection, test preparation, and server-side verification, and performed the desktop and phone publishing checks myself. This was not a migration of my live WordPress site.

Collections and fields are stored in the database and can be changed through administrative interfaces. That flexibility has teeth. The 1.0.1 schema implementation confirms that deleting a field can drop its database column and values; deleting a collection can drop its content table. Generated types describe the model, but they do not make destructive changes reversible. This is a programmable CMS, not a way to avoid owning the consequences of application development.

The Publishing Workflow Held Up

For the editorial test, I used five representative JimLunsford.com posts and my About page. The set included a regular long-form article, a sourced recovery article, a Recovery Standard, a Discipline Dispatch, and a software-building article. I wanted my actual writing in the editor, not six short examples designed to make the CMS look good.

On a disposable copy of the long article, I saved a draft, previewed it, and published it. Preview showed the saved draft while server-side checks confirmed that the ordinary public URL kept it hidden. Publishing then made the saved content public.

The more important test came afterward. I edited the published article and saved an unpublished revision. The changed sentence existed in the draft, but readers still received the previous live version. Preview showed the new sentence privately. Only explicit publication advanced the live revision and cleared the draft pointer.

That is the editorial contract I care about. A working draft should not become live merely because I saved it. EmDash’s separation between the live revision and unpublished work was visible in both the interface and the underlying state.

Scheduling a revision worked too. The existing version stayed public while the changed revision waited. EmDash promoted the waiting revision internally about 32 seconds after the configured time. By my next public check, 86 seconds after that time, the changed version was visible.

A separate cache test was useful. With the Node memory cache primed and set to a one-hour lifetime, a scheduled post appeared shortly after the scheduler processed it. Publication itself invalidated the collection cache. The tested runtime did not leave the post hidden for an hour, despite the object-cache guide‘s warning about waiting for another change or expiry.

Trash removed the article from public access. Restore preserved its body, all five historical revisions present at that point, and the Projects category, but returned it as a private draft. It did not silently republish the article. The old publication timestamp remained in the record, so status and active revision pointers mattered more than that timestamp alone.

I then opened the restored long article on my phone, added a sentence, saved it as a draft, and verified it in preview. The server confirmed that the edit stayed private. I could make and check that change without returning to the desktop.

WordPress Migration Worked, but It Was Not a Site Clone

The representative writing arrived through a generated WordPress WXR file built from captured public JimLunsford.com content. All five posts and the page imported and rendered publicly. The regular long-form article kept its H2 headings and inline links, and an unchanged second import skipped all six rather than creating duplicates.

That sample contained no attachment records. I tested richer content and media separately rather than treating those six items as proof that an entire WordPress site had moved.

A separate test used EmDash’s realistic WordPress fixture. That imported posts, pages, a custom book collection, taxonomies, guest bylines, and a reusable section. Published items stayed published, and draft items stayed private. The unchanged rerun created no duplicate content, sections, or terms.

Media required its own stage. The content import initially preserved WordPress attachment URLs. Using two controlled image files, the media-copy endpoint created local records, a repeated copy reused them by content hash, and the rewrite operation changed the relevant content references. The resulting local media URLs loaded successfully.

There were rough edges. The analyzer lost the fixture’s channel-level site title and URL, returning fallback metadata even though the file contained those values. One rewritten image reference retained an external-provider label, which later produced a transfer warning even though the managed media records and bytes transferred correctly.

The WordPress migration documentation also describes boundaries that prevent this from being a clone operation. Complex blocks, shortcodes, and plugin-defined content need review. The documented status mapping does not reconstruct WordPress schedules and visibility rules wholesale. Those are migration decisions, not details an operator should discover after switching traffic.

My conclusion is that the importer is useful, but conversion still needs editorial inspection. Getting the words into a new database is only one part of moving a publishing system.

A Theme Means Owning the Astro Project

An EmDash theme is a starting application, not an installed skin I can switch at runtime. The Starter project’s own instructions put visual design in site-owned Astro source. That is a different arrangement from WordPress theme switching and from Bonumark’s code-free runtime presentation packages.

I tested the model with a small stylesheet and an import in Base.astro. The change added basic navigation, article width, typography, spacing, and mobile styling. After rebuilding and redeploying the application, the public pages rendered normally, the private draft stayed private, and the existing sandbox plugin remained healthy. I checked the result on desktop and phone.

The first build failed because the deployment script created a directory the build user could not traverse. The rollback restored the previous source and build. Correcting the ownership made the next build succeed. That was a mistake in our deployment tooling, not an EmDash theme defect, and the rollback was our safeguard rather than an automatic CMS feature.

The tradeoff is straightforward. A developer gets direct control over the public application, but also owns its build and deployment. Someone expecting to change the whole site’s appearance through an admin theme picker is expecting a different product. I would choose this model deliberately, not discover it halfway through a migration.

The Agent Interface Has Rules Worth Using

EmDash’s agent support became more convincing when I stopped looking at the presence of MCP and started looking at the contracts behind it. MCP, the Model Context Protocol, gives an agent structured tools for interacting with the site. EmDash also supplies a documentation index, versioned agent skills, starter-project guidance, and a CLI.

The strongest example was revision-aware updating. The live MCP content_update schema required the current _rev value and told the client to read the item first. Omitting the revision failed validation. Reading an entry, changing it through another interface, and then submitting the old revision produced CONFLICT. Rereading and supplying the fresh revision allowed the update.

The agent could not simply assume its earlier view was current. It had to submit the change against the state it had actually read. That is more useful to me than another feature that generates a paragraph.

The guarantee was not uniform across interfaces. The CLI update command also required a revision, but the raw REST update and typed-client paths accepted writes without one. EmDash provides a stricter agent mutation contract; it does not make every possible client equally strict. An integration needs to know which interface it is using.

There was a more direct contract failure on reads. The CLI’s content get --published flag promised to return only the live version. With an unpublished revision waiting, it still returned the draft in data and the old public version in liveData.

The 1.0.1 CLI implementation explains why: the flag skipped a local overlay step but never requested a published-only response from the HTTP client. The public site stayed correct, but an automation trusting that flag could work from the wrong version.

I also tested two narrowly scoped API tokens tied to my administrator account. A content:read token could list content, but content creation failed with INSUFFICIENT_SCOPE. Adding content:write allowed creation of a private draft, while a settings update still failed because settings:manage was absent. After each token was actually revoked, the previously working credential returned HTTP 401 with INVALID_TOKEN.

Read-only did not mean published-content-only. The administrator’s read token could see unpublished editorial material. Content write was broader than editing a paragraph too: with the administrator role behind it, the token could trash and permanently delete the controlled probe. The 1.0.1 authorization model combines token scopes with the human account’s permissions. I would not grant that token’s content:write scope as though it were editing-only access.

Tool discovery was less precise. The read-only client still saw 72 tools, including content creation and settings mutation tools it could not execute. Permission checks held when those operations were called, but the advertised tool list was not a clean picture of usable authority. An agent has to handle refusal rather than treating discovery as permission.

This is what I mean by AI-legible software: the running application exposes enough state, rules, and failure information for an automated client to act within a defined contract. Legibility reduces ambiguity. It does not remove responsibility.

The Sandbox Held at the Boundaries I Tested

For plugin testing, I used a disposable plugin running in a separate workerd process under the Node deployment. It received content:read and network:request, with one allowed network host. Content reads succeeded. Media and taxonomy operations without the required capabilities failed at the host bridge. Requests to the allowed host worked; requests to another host were refused.

Ordinary global fetch calls did not provide an unrestricted route around that bridge. They were directed to the private backing service and rejected. The runtime probe also saw no host environment-variable keys. A route deliberately sleeping for 31 seconds was terminated at the 30-second wall-time limit without restarting the main EmDash process or its workerd child.

That 30-second cutoff does not mean every resource limit is enforced on Node. The 1.0.1 workerd configuration code explicitly leaves per-plugin CPU, memory, and subrequest budgets unenforced in standalone workerd. Restricting what a plugin can access and limiting how many resources it can consume are different jobs.

Native plugins are a different trust class again. They run inside the Astro process rather than this sandbox, so these isolation results do not apply to them.

The capability documentation was imprecise in one important way. It said some APIs would be absent without permission. In the tested wrapper, media and taxonomy proxy objects were present, but calls through them failed. The security boundary held; the documented object shape did not match it. Checking whether an API property exists is not the same thing as checking whether the operation is authorized.

The dependency cost deserves attention too. Installing the Node sandbox runner produced three high-severity npm audit findings in its transitive optional dependency chain. The study service ran workerd rather than Miniflare, and the tests did not demonstrate an exploitable vulnerability in that running path. I would neither describe that as a proven compromise nor hide the findings behind successful isolation tests.

Plugin Updates Required Fresh Permission

I tested permission growth separately from runtime capability enforcement. A disposable managed plugin began with content:read. Its next version added media:read. An update request without confirmation returned HTTP 403 and CAPABILITY_ESCALATION, named the added permission, and left the installed version unchanged.

The admin then showed a blocking “Review New Permissions” dialog. It identified the new version, marked “Access your media library” as “NEW,” and offered “Cancel” or “Accept & Update.” Only after I accepted did version 2.0.0 install.

That is useful consent behavior. My criticism is the wording: “Access your media library” is understandable, but it does not clearly say read-only. The underlying capability was more precise than the human explanation.

This used a controlled localhost marketplace and its legacy managed-update path, not the newer registry workflow. It proved that the update stopped for consent and installed only after approval; the disposable plugin did not make a media read afterward. I removed the plugin and temporary marketplace configuration when the test was done.

Moving a Site Is Not the Same as Recovering It

EmDash’s site-transfer flow was one of the strongest parts of the study. The .emdash package was analyzed before the target’s content changed. The plan identified source and target, counted records, mapped the source author to an existing target user, showed starter records to remove, and reported warnings. Execution had to match the package and plan digests, the fingerprints of exactly what I had reviewed.

The completed transfer produced a receipt with record counts and a logical digest, a fingerprint of the imported data. The package moved schema, entries, revisions, taxonomies, bylines, menus and widgets, selected settings, and two managed media files. Users and authentication had been prepared separately on the target. The package did not transfer those credentials.

That distinction also applies to backups. The backup documentation explicitly says the convenient JSON backup is not a complete restore point. A portable site package is not one either. For the Node recovery test, I needed the full SQLite database, media files, matching application version, and separately preserved encryption-key material.

I stored a disposable encrypted plugin setting and recovered the database and media into another application copy without its key. The site, content, authentication, and media worked, but reading the encrypted setting failed. Restarting that same recovered copy with the matching key made the existing value readable without re-entering it or rewriting the database.

Key rotation worked in the tested read-old, write-new sequence. Keeping the old key behind a new primary key allowed old ciphertext to be read. Saving the setting again moved its envelope to the new key. Old-key-only access then failed, while new-key-only access succeeded.

Those boundaries matter more than the label on an export button. A usable content package and a recoverable deployment are different promises. EmDash’s distinction was supported by the recovery exercise, but the operator still has to preserve the right material.

I Killed the Import. Recovery Was Not Immediate.

The first interruption design was wrong. The 31.6 KiB test package completed in a single advance request, roughly 177 milliseconds after the mutation-start timestamp. A bounded request does not necessarily leave a small import unfinished. The harness correctly refused to count a completed import as an interruption test.

The revised test watched the durable operation row and sent SIGKILL to the target process while the first advance request was in flight. It caught the import at the beginning of clear_scaffold, with mutationStartedAt recorded and no completion timestamp. The connection died, the target became unreachable, and the operation survived in the database.

This was an early crash checkpoint: mutationStartedAt had been recorded, but no posts, pages, or media had been imported. It did not test a crash halfway through a large content or media copy.

After restart under a new process, the same operation, cursor, and progress were present. Repeating execute returned the existing running operation rather than creating a replacement. But subsequent advance calls kept returning the same checkpoint. The dead process’s five-minute lease, its temporary claim on the operation, had not expired.

Our client stopped after 100 two-second retries, before that lease window ended. Once the recorded expiry had passed, a new client resumed the same operation and the next advance completed it. The receipt’s logical digest matched the earlier uninterrupted transfer. The expected content and media counts matched too, the tested public article loaded, and the draft stayed private.

That is a real recovery result with a real delay. EmDash survived abrupt process death at the tested checkpoint, but restart did not mean immediate takeover. During that wait, repeated unchanged progress was accurate without being especially explanatory. A client that understands the lease has a better chance of distinguishing waiting from failure.

The setup also exposed mistakes in our scripts: file permissions, a shared dependency directory, and stale copied authorization. Those failures happened before the crash experiment. They belong to our test tooling, not EmDash’s transfer engine.

The Remaining Edges Matter to an Operator

Getting back into the admin is only half of authentication recovery. EmDash refused to delete my account’s last passkey, which is a useful guard. In a separate experiment on a disposable recovery copy, I manually deleted the sole user from the database. That reopened first-admin setup and produced options to register a replacement passkey.

EmDash’s authentication guide did not supply a step-by-step database recovery procedure; deleting the user was my test method, not a documented instruction. Existing content, media, and revisions still referred to the deleted user ID, while the replacement-admin code creates a new ID. I did not complete the replacement passkey registration.

An operator needs a recovery procedure that preserves or deliberately reassigns ownership, not just a way back into the admin.

I tested one Node/SQLite/local-media deployment, not the PostgreSQL, Cloudflare D1/R2, or other storage combinations. The work did not include a full user-role and edit-lock matrix, a complete accessibility assessment, a large-site load test, or a production upgrade between EmDash releases.

What I Would Use, and What I Would Leave Alone

For a developer building an Astro site who wants structured content, an editor-facing admin, and built-in tools for automation, EmDash 1.0.1 makes sense. The publishing workflow kept draft work separate from the live site. MCP refused stale updates, the tested plugin permission checks held, plugin updates stopped for added permissions, and transfer receipts gave me something concrete to verify.

I would be more cautious where the expectation is a code-free theme switch, a faithful replacement for an established WordPress plugin stack, or recovery that requires little operator knowledge. The migration cleanup, gaps in Node resource limits, dependency advisories, and recovery-documentation gap are responsibilities I would have to accept before choosing this stack.

I am not moving JimLunsford.com off WordPress because another CMS has a cleaner agent interface. My publishing system includes authoring Docs, editorial review, content-family conventions, SEO handling, automation, and a downstream reference library. Moving the articles would not recreate that system. The study did not establish a reason to replace it.

Bonumark is a separate comparison. It is intentionally a focused, single-owner microblog CMS with behavior in core and code-free presentation packages. I do not want to import arbitrary content schemas, a general plugin ecosystem, or the Node/Astro/workerd stack merely because EmDash uses them coherently.

What I would carry forward is smaller and more useful: current rules that an agent can discover, writes that prove what state they read, reviewed plans before dangerous changes, and evidence after completion. That builds on how I already work with AI without turning a technology study into a feature-copying exercise.

EmDash earned my respect as a publishing system, not just as an AI demonstration. Its rough edges are visible enough to discuss precisely, and several of its strongest ideas make ordinary operations safer to reason about. The lesson that stayed with me is not to put more AI inside the product. It is to make the running software clear about what can change, who can change it, and how we know the work actually held.


New Here?

Read Next:

Sources and Test Scope

First-hand results come from my completed EmDash 1.0.1 study. Inline source-code links point to the exact release commit examined, not the moving main branch. Documentation links describe the product and may have changed since testing; they are not independent reproductions of my results.


Get the Work
Articles on discipline, recovery, identity, and ownership. Delivered when published.