Lesson 13 — Pro Patterns: Async, Hints & Network
Once your scripts work, the next step is making them work reliably in production — handling errors gracefully, chaining async operations safely, talking to external services, and saving scripts for reuse. This lesson covers doc.promises for async workflows, HTTP networking, timers, and the community hint system.
These patterns matter when scripts move from personal experiments to tools other people use. A script that crashes on an unsaved document, ignores a failed export, or blocks the UI during a long operation is frustrating. The techniques here — promise chaining, mustSaveAs checks, error handling, and the hint pool — are what separate a finished tool from a fragile prototype.
Two ideas are worth holding onto:
- Async has a specific shape.
doc.promiseswraps operations likeexecuteCommand,export,save, andsaveAsin Promises you canawait. The synchronousdoc.executeCommand/doc.exportdo not return promises and can't be awaited. Mixing both in one async function is a common source of ordering bugs — pick one style and use it throughout. - Always check
mustSaveAsbefore saving. New, never-saved documents have no file path;doc.mustSaveAsistrueanddoc.promises.save()fails on them. Branch onmustSaveAsto decide betweensaveAs()andsave().
The hint system — search_sdk_hints and add_sdk_hint — is a community knowledge base in the SDK tooling. When the docs don't cover a behaviour, searching the hint pool often surfaces a working pattern. When you discover something new, contributing a hint makes it available to everyone. Checking the hint pool before debugging from scratch is one of the highest-leverage habits in SDK development.
What this lesson covers
- The
doc.promisesAPI — full Promise-based workflow - Chaining async operations safely
- Error handling in async scripts
network.js— HTTP from scriptstimers.js— timeouts and intervalssearch_sdk_hints— community knowledge baseadd_sdk_hint— contributing solutions backsave_script_to_library— saving production scripts- Building robust, production-grade scripts
The doc.promises API
Every document operation has a Promise-based equivalent under doc.promises. This is the preferred pattern for scripts that do multiple sequential operations.
'use strict';
const { Document, DocumentPromises } = require('/document.js');
const { app } = require('/application.js');
async function main() {
// Get current document (Promise-based)
const docP = await DocumentPromises.getCurrent();
if (!docP) {
console.log('No document open.');
return;
}
const doc = docP.document;
console.log('Document:', doc.title);
// Save — new/unsaved documents need saveAs first
try {
if (doc.mustSaveAs) {
// Document has never been saved — save to Desktop first
const desktop = app.userDesktopPath;
await docP.saveAs(`${desktop}/${doc.title || 'untitled'}.af`);
console.log('Saved to Desktop.');
} else {
await docP.save();
console.log('Saved.');
}
} catch (err) {
console.log('Save failed:', err);
}
}
main();
doc.mustSaveAs is true for unsaved documents. Calling docP.save() on a document that has never been saved throws SAVE_TO_TEMPORARY_ARCHIVE_ERROR. Always check doc.mustSaveAs and use docP.saveAs(path) with a Desktop path for new documents.
Processing and exporting the current document
'use strict';
const { DocumentPromises, FileExportOptions, FileExportArea } = require('/document.js');
const { app } = require('/application.js');
async function main() {
const docP = await DocumentPromises.getCurrent();
if (!docP) { console.log('No document open.'); return; }
const doc = docP.document;
const desk = app.userDesktopPath;
// Export as PNG to the Desktop
const opts = FileExportOptions.createWithPresetName('PNG');
const area = FileExportArea.createForWholeDocument();
const outPath = `${desk}/${doc.title}.png`;
try {
const records = await docP.export(outPath, opts, area, null);
const rec = records.all[0];
console.log(rec?.isSuccess ? 'Exported: ' + outPath : 'Export failed.');
} catch (err) {
console.log('Export error:', err);
}
}
main();
Full async pipeline
Load → generate AI image → export → save → close, all chained safely:
'use strict';
const { DocumentPromises, FileExportOptions, FileExportArea } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { app } = require('/application.js');
async function generateAndExport(prompt) {
const desktop = app.userDesktopPath;
const docP = await DocumentPromises.getCurrent();
if (!docP) throw new Error('No document open.');
const doc = docP.document;
// Set spread before AI command
await docP.executeCommand(
DocumentCommand.createSetCurrentSpread(doc.currentSpread)
);
// Generate image (async — resolves when generation starts)
doc.generateImage(prompt);
// Export
const opts = FileExportOptions.createWithPresetName('PNG');
const area = FileExportArea.createForCurrentSpread();
const path = `${desktop}/generated.png`;
const records = await docP.export(path, opts, area, null);
// Save — check mustSaveAs for unsaved documents
if (doc.mustSaveAs) {
await docP.saveAs(`${desktop}/generated.af`);
} else {
await docP.save();
}
return records.all[0]?.path ?? null;
}
async function main() {
try {
const path = await generateAndExport('Abstract blue waves');
console.log('Done:', path);
} catch (err) {
console.log('Pipeline failed:', err.message ?? err);
}
}
main();
Network requests
Network access requires permission. Enable it in Affinity Settings → Scripts.
HttpRequest.create()throwsPERMISSION_DENIEDif not enabled. The response body is inresult.response.content(not.body).
'use strict';
const { HttpRequest, RequestMethod } = require('/network.js');
// GET
try {
const req = HttpRequest.create('https://jsonplaceholder.typicode.com/todos/1', RequestMethod.Get);
req.setTimeoutInSec(10);
const result = req.do();
if (result.response.statusCode == 200) {
const data = JSON.parse(result.response.content);
console.log('Title:', data.title);
}
} catch(err) {
console.log('Network error:', err.message);
// If PERMISSION_DENIED: enable network access in Affinity Settings
}
// POST with JSON body
try {
const req = HttpRequest.create('https://jsonplaceholder.typicode.com/todos', RequestMethod.Post);
req.setHeaderValue('Content-Type', 'application/json');
const result = req.do();
console.log('Response status:', result.response.statusCode);
console.log('Response body: ', result.response.content);
} catch(err) {
console.log('Network error:', err.message);
}
Remote data → shapes pattern
Fetch data from an API and create one shape per item:
'use strict';
const { Document } = require('/document.js');
const { CompoundCommandBuilder } = require('/commands.js');
const { HttpRequest, RequestMethod } = require('/network.js');
const { RGBA8 } = require('/colours.js');
const doc = Document.current;
if (!doc) { console.log('No doc.'); }
else {
let items = [];
try {
const req = HttpRequest.create('https://jsonplaceholder.typicode.com/todos?_limit=5', RequestMethod.Get);
req.setTimeoutInSec(10);
const result = req.do();
if (result.response.statusCode == 200) {
items = JSON.parse(result.response.content);
}
} catch(e) {
console.log('Network error:', e.message);
// If PERMISSION_DENIED: enable network access in Affinity Settings
}
console.log(`Fetched ${items.length} items.`);
items.forEach((item, i) => {
const colour = item.completed ? RGBA8(80, 200, 120, 255) : RGBA8(220, 80, 60, 255);
// ... build and add shape nodes here
// See Lesson 06 for AddChildNodesCommandBuilder usage
console.log(`${i + 1}. ${item.title} — ${item.completed ? 'done' : 'pending'}`);
});
}
Timers
'use strict';
const { setTimeout, setInterval } = require('/timers.js');
// setTimeout(delay, callback) — delay in milliseconds, callback receives errorCode
setTimeout(2000, (err) => {
if (err) { console.log('Timer error:', err); return; }
console.log('Ran after 2 seconds.');
});
// setInterval(delay, callback) — repeats until timer.cancel() is called
let count = 0;
const timer = setInterval(1000, (err) => {
if (err) return; // err is set when cancelled
count++;
console.log('Tick', count);
if (count >= 3) timer.cancel();
});
Timers only run while the script engine is active. Do not use them for deferred saves or long idle waits — the engine may not be running.
search_sdk_hints — community knowledge base
Before experimenting on a hard problem, always search hints first:
// Via the MCP tool (when using Claude with Affinity):
search_sdk_hints("How to replace text in a frame without appending")
search_sdk_hints("TextSelection end - 1")
search_sdk_hints("setCurrentSpread clears selection")
This queries a global pool of solved problems from other sessions. It often returns exactly the gotcha you're about to spend an hour debugging.
add_sdk_hint — contributing back
When you solve a novel problem through experimentation, add a hint:
// Via the MCP tool:
add_sdk_hint("To replace all text in a TextNode, create a TextSelection with
end = storyRange.end - 1. Using storyRange.end causes text to append rather
than replace.")
Your hint will be visible to all future sessions.
save_script_to_library
When a script is working reliably and you want to keep it, use the Affinity_save_script_to_library MCP tool to persist it to the script library. The library stores scripts in scripts.propcol — a proprietary binary file. They are not accessible as .js files from within scripts at runtime.
There is no Save to Library button in the Scripts panel UI. Library saving is available via the MCP server.
Production script checklist
Before shipping a script for others to use:
- Guard against no document open (
if (!doc) return) - Guard against empty selection (
if (sel.nodes.isEmpty) return) - Use compound commands for multi-step changes (one undo step)
- Capture
doc.selectionBEFORE anysetCurrentSpreadcall - Wrap file operations in try/catch
- Log success/failure clearly with
console.log - Use
app.userDesktopPathfor all file paths - Test with a document that has multiple spreads
- Test with nothing selected (should fail gracefully)
- Save to library when stable
Key gotchas
Always call main() at the bottom. Async functions are not auto-invoked.
setTimeout(delay, callback) — delay comes first. This is the opposite of Node/browser where the callback is first. Passing a callback as the first argument produces TypeError: expected parameter callback to be Function from waitAsync. The callback receives an errorCode which is set on cancellation.
timer.cancel() not clearInterval(timer). The SDK timer returns an object with a .cancel() method. There is no global clearInterval.
generate_image is fire-and-forget. It starts the generation but returns immediately. Chain with executeCommandAsync callback if you need to act on the result.
search_sdk_hintsbefore experimenting. Many subtle bugs (selection cleared by setCurrentSpread, end-1 rule, Dialog.Ok casing) are already documented. Search first.
Exercises
Exercise 13.1 — Async pipeline
Write a fully async script using doc.promises that: loads the current document, exports it as PDF, then saves it — with proper error handling at each step.
Exercise 13.2 — Remote shapes
Fetch a JSON array from https://jsonplaceholder.typicode.com/todos?_limit=8. For each item, log its title and completion status. (Bonus: create a coloured shape for each.)
Exercise 13.3 — Production tool
Take your favourite script from any earlier lesson and harden it with the production checklist above. Add a dialog for configuration and save it to the library.
Exercise 13.4 — Hint contribution
Solve a problem you encountered during the tutorial that isn't covered in the preamble hints. Formulate it as a clear, concise hint and note it down for contributing via add_sdk_hint.
SDK modules used in this lesson
/document—DocumentPromises/network—HttpRequest,RequestMethod,HttpStatusCode/timers—setTimeout,setInterval,setImmediate/commands—DocumentCommand/fs—File,Buffer,Directory,FileSystemPromises
Further examples
| Example | What it shows |
|---|---|
logToFile.js | /logging (LogFile), /fs file I/O, setTimeout timing patterns |
See also
Example scripts
13a-async-pipeline.js
Download 13a-async-pipeline.js
'use strict';
// Example 12a — Full async pipeline
// Lesson 12: Pro Patterns
//
// Loads the current document, exports as PNG, then saves.
// Demonstrates the doc.promises pattern with error handling.
const { Document, DocumentPromises, FileExportArea, FileExportOptions } = require('/document.js');;
const { app } = require('/application.js');
async function main() {
const desktop = app.userDesktopPath;
// Step 1 — get current document
let docP;
try {
docP = await DocumentPromises.getCurrent();
} catch (err) {
console.log('Could not get document:', err);
return;
}
if (!docP) {
console.log('No document is open.');
return;
}
const doc = docP.document;
const title = doc.title;
console.log('Document:', title);
// Step 2 — export as PNG
const opts = FileExportOptions.createWithPresetName('PNG');
const area = FileExportArea.createForWholeDocument();
const outPath = `${desktop}/${title}.png`;
let records;
try {
records = await docP.export(outPath, opts, area, null);
} catch (err) {
console.log('Export failed:', err);
return;
}
const rec = records.all[0];
if (!rec?.isSuccess) {
console.log('Export error:', rec?.errorMessage ?? 'unknown');
return;
}
console.log('Exported to:', rec.path);
// Step 3 — save the document
// New/unsaved documents need saveAs() first — save() throws SAVE_TO_TEMPORARY_ARCHIVE_ERROR
try {
if (docP.document.mustSaveAs) {
await docP.saveAs(`${desktop}/${title}.af`);
} else {
await docP.save();
}
console.log('Document saved.');
} catch (err) {
console.log('Save failed:', err);
}
console.log('Pipeline complete.');
}
// Always call the async entry point at the bottom
main();