Every document-generation product for Power Platform we could find does its rendering somewhere else. The record leaves Dataverse, gets merged into a template on a vendor's infrastructure, and comes back as a file. That architecture is not lazy; it is the obvious one, because the place the record already lives, the Dataverse plug-in sandbox, is a hostile environment for anything that touches fonts, images or large binaries.
We built VerseDocs the other way round: the whole engine runs as plug-ins inside the customer's own environment, so the document bytes never leave it. This post is the engineering account of what that took. It is written for people who build on Dataverse, and it is honest about the parts that were harder than expected.
The constraints, as they actually are
The plug-in sandbox is a .NET Framework 4.6.2 worker on Dataverse's own servers running pure managed code. From the documentation and from what we hit in practice:
- No filesystem. Anything that wants a temp file is designed wrong for this environment; every stream is a
MemoryStreamend to end. - No
System.Drawing. The worker has no GDI+, so any library that draws has to do it in managed code. We use ImageSharp, pinned to the 2.1.x line because 3.x changed licence and dropped netstandard2.0. - No native DLLs and no
Reflection.Emit. Several popular document libraries fail one or both tests. - Dependencies must ship netstandard2.0 binaries. Modern NuGet majors silently drop it, so every package version is pinned centrally and the build fails on drift.
- A hard two-minute execution timeout with no async pattern. We never measured the true ceiling; we set an internal budget of 90 seconds and design to it.
- Workers recycle. Statics are a cache, never a guarantee, and anything expensive to compute has to be cheap to recompute.
The package-size ceiling nobody had measured
Folk wisdom says a plug-in package has to stay under 16 MB. We designed to that number for months. Then we measured it.
Uploading progressively larger packages through the Web API, a 45.79 MiB package succeeds and a 46.79 MiB package fails with 0x80040265, "Message size exceeded when sending context to Sandbox." The real ceiling is roughly 46 MiB of raw package, nearly three times the figure everyone repeats. The organisation's maxuploadfilesize setting, which is the first thing people check, turned out to be irrelevant to plug-in packages entirely.
That single measurement changed the product. It is the difference between shipping a fallback renderer and a full one, and between embedding two fonts and twelve. If you are building anything substantial in the sandbox, measure the ceiling yourself before you cut features to fit a number from a forum post.
The sandbox has no fonts
This is the one that bites every document project, and it bites late, because it works perfectly on the developer's machine.
PDF libraries resolve fonts from the operating system by default. On a Windows dev box that finds Arial, Calibri and Times. In the sandbox it finds nothing, and depending on the library it either throws or silently substitutes something ugly. In our case the first live conversion collapsed every typeface in the document to an unembedded monospace face, so a serif letter came back looking like a terminal window.
The fix is to embed fonts as managed resources and register a font resolver that serves them from the assembly. We ship the Liberation family, which is metric-compatible with Arial, Times New Roman and Courier New, so a template designed against those faces lays out identically. Twelve faces cost about 2.4 MB of package; with the measured ceiling above, that is affordable.
Two details cost us a day each. First, registering the resolver "on first use" raced under parallel execution: 17 of 108 tests failed intermittently until registration moved into a module initialiser that runs once, before any code path can need it. Second, Word bullets are not the character you think they are. They are a private-use-area glyph in the Symbol font, and font substitution never fires for them, so bulleted lists came back blank. We rewrite the numbering definitions to a real bullet character with an explicit font at template-load time.
Word splits your tokens across runs
Templates use {{tokens}} in ordinary Word documents, which is the only syntax a business user will tolerate. The problem is that Word stores a paragraph as a sequence of runs, and it fragments text across runs for reasons invisible to the author: a spellcheck pass, a formatting change three edits ago, a paste. {{customer.name}} is routinely stored as {{cust + omer.na + me}}.
Every home-grown OpenXML generator we have seen breaks on this. The engine normalises runs within each paragraph before it scans for tokens, merges the fragments, and preserves the formatting of the first run so the rendered value inherits the author's styling. The test suite includes fixtures with hand-edited XML that fragment tokens in every way we have seen Word do it, because a fixture Word wrote itself will usually be clean and prove nothing.
Loops work the same way at a larger scale. When {{#each lines}} and {{/each}} land in the same table row, the row is the repeat unit and is duplicated per item; across rows, the row range is; otherwise the paragraph range is. The author never configures this, because the alternative is a syntax nobody would learn.
Converting to PDF without leaving the tenant
This was the hardest decision in the product and the one we changed our minds on.
The obvious route is to hand the DOCX to a conversion service. Power Automate users do this with OneDrive's Convert file action; a plug-in can do it through Microsoft Graph. We built the Graph path completely: the plug-in used a managed identity to upload the document to a dedicated SharePoint site in the customer's own tenant, asked Graph for the PDF rendition, and deleted the source in a finally. It worked. The bytes stayed inside the customer's Microsoft 365 tenant throughout.
We removed it before shipping, for a reason that had nothing to do with rendering. The managed identity bound the plug-in package to a fixed-GUID record that had to exist in the target environment before import, which broke a one-click AppSource install, and the Entra app registration it needed was a per-tenant setup step. The moment the answer to "what do I need?" stops being "install the solution," the positioning of an in-tenant product collapses. So conversion had to happen in-process, in the sandbox, in managed code, with no fonts and no GDI+.
What ships is a two-rung ladder. The first rung is a commercially licensed .NET rendering library that we confirmed runs clean in the sandbox despite statically referencing System.Drawing; the references exist, but the code paths that use them never execute for DOCX-to-PDF, and static assembly inspection would have wrongly ruled it out. The second rung is our own managed DOCX renderer, built on PDFsharp and OpenXML, with 363 tests and a deliberately narrower feature scope; it is the fallback if the first rung throws for any reason. Every rung reports which engine produced the output, so a support question never starts with a guess.
The honest trade-off: the commercial rung renders floating text boxes, tab stops, page-number fields and footnotes that the in-house renderer cannot, at the cost of about 7 MB of package and a licence dependency. Multi-column layouts honour column width but do not yet flow true two-column text in either rung. We say so in the docs.
What the wire can carry
Files travel as base64 strings in Custom API parameters, are processed in memory, and return as base64. That is simple and it is also a 33% inflation on every byte, so we measured the practical ceiling with an echo action that does no work.
| Raw file | On the wire | Round trip | Server time |
|---|---|---|---|
| 5 MB | 6.7 MB | ~4.2 s | 51–119 ms |
| 25 MB | 33.3 MB | 14–17 s | 208–307 ms |
| 40 MB | 53.3 MB | 20–22 s | 393–519 ms |
| 90 MB | 120 MB | 46–108 s | ~780 ms |
| 100 MB | 133 MB | fails | HTTP 413 |
Server-side processing stays under a second even at 90 MB; almost the entire cost is HTTP transfer and OData deserialisation. Around 100 MB the gateway refuses the request outright, and at 90 MB one call in three failed with a generic sandbox error. We tell customers to treat 40 to 50 MB as the safe single-call maximum and to think in pages rather than megabytes, because page count is what actually correlates with conversion time, at roughly 23 to 29 pages per second.
Two other measurements worth having before you promise anyone a number. A cold sandbox worker costs about 17 seconds before your code runs at all, and the same call that takes 5 seconds warm takes 22 cold; run-to-run variance in the sandbox exceeded 10x in our tests, with a 2,000-row render taking 83 seconds one run and a 3,000-row render 6 seconds the next. And roughly 20 minutes of cumulative plug-in execution from one user tripped service protection and suspended that user's API access for about 20 minutes. The batch action caps itself at 25 rows and a 90-second budget for exactly that reason, and it refuses row 26 in under 200 milliseconds rather than trying.
Licensing that never calls home
An in-tenant product with a licence check that phones home has given away its own argument. A key is a signed payload naming the organisation or tenant it applies to, an edition and an expiry; the plug-in validates the signature against an embedded public key and compares the scope to the executing organisation's ID, which comes from platform context and cannot be spoofed by the caller. No network call, so it works in air-gapped and GCC-style tenants, and a leaked key is useless anywhere but the tenant it names.
Unlicensed, the product is fully functional and every output carries a trial watermark. That watermark is applied to the output PDF rather than the source DOCX, after we discovered that one rendering engine silently drops VML watermark shapes on conversion, which would have made the trial free by accident. We also found, only by testing against a live environment, that a document with no section properties skipped the watermark entirely. Both are now tested.
The product does send aggregate usage counts, such as calls by action and template count, so we know what is used; document content, file names, template content and field values are never included, and there is a switch to turn it off. We think that is the right line, and we think it should be stated plainly rather than buried.
Three lessons we would give anyone building in the sandbox
Change the assembly version on every build. Dataverse caches a loaded assembly by name and version, so pushing new IL under an unchanged version leaves workers running the old code and the deploy silently does nothing. This cost us an afternoon of debugging code that was never running.
Obfuscate strings, not names. A renaming pass renamed an internal type that the JSON deserialiser constructs by reflection, and every Custom API broke in production while every unit test passed. The smoke suite now runs against the obfuscated package before anything ships.
Never export a solution from an environment that holds a real environment-variable value. pac solution export writes the exporting environment's current values into the package, and ours carried a real licence key two zip levels deep before a scanner caught it. The key was org-scoped, so it could not have licensed anyone else, but the build now deletes captured values before packing and fails if any secret-shaped string survives into a shipping artifact.
Where this leaves things
VerseDocs generates Word, Excel, PowerPoint and PDF from Dataverse records, with loops, conditionals, images and totals in the template, and converts, merges, splits, watermarks and signs PDFs, all inside the plug-in sandbox. It is on AppSource with a free trial that never expires. The template syntax and limits pages carry the measurements above in more detail, including the ones that did not flatter us.
If you are building something heavy in the sandbox and have measured something we have not, we would like to hear about it.