Agent Wranglers

Forms Recommended

Save drafts on their own

When a screen saves without a button, save within 2 seconds of the last change and when the page is hidden. Show the save state in words, and never overwrite someone else's change.

How to do it

Collect the changed fields, and send them all after a pause of 1 to 2 seconds. Also save on visibilitychange to hidden, with keepalive so the request outlives the page. Never send two saves at once. Send the version you started from with If-Match, as ETags describes. Show "Saving", "Saved at 14:02" or "Not saved, retrying" in a status region. On 412, show both versions and let the person choose.

Example

const PAUSE_MS = 1500;
const RETRY_MS = 5000;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending: Partial<Note> = {};
let isSaving = false;
let version = initialVersion;

export function scheduleSave(changes: Partial<Note>) {
  pending = { ...pending, ...changes };
  clearTimeout(timer);
  timer = setTimeout(save, PAUSE_MS);
}

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') save(); });

async function save() {
  clearTimeout(timer);
  if (isSaving || Object.keys(pending).length === 0) return;
  const changes = pending;
  pending = {};
  isSaving = true;
  let nextTryMs = PAUSE_MS;
  setSaveStatus('Saving');
  try {
    const response = await fetch(`/api/notes/${noteId}`, { method: 'PATCH', keepalive: true, headers: { 'Content-Type': 'application/json', 'If-Match': version }, body: JSON.stringify(changes) });
    if (response.status === 412) return showConflict(changes, await response.json());
    if (!response.ok) throw new Error(`Save failed with ${response.status}`);
    version = response.headers.get('ETag')!;
    setSaveStatus(`Saved at ${new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(new Date())}`);
  } catch {
    pending = { ...changes, ...pending };
    setSaveStatus('Not saved, retrying');
    nextTryMs = RETRY_MS;
  } finally {
    isSaving = false;
    if (Object.keys(pending).length > 0) timer = setTimeout(save, nextTryMs);
  }
}
@app.patch("/api/notes/<note_id>")
def update_note(note_id):
    expected = request.headers.get("If-Match")
    if expected is None:
        return problem(428, "if-match-required")
    updated = notes.update_if_version(note_id, expected.strip('"'), request.json)
    if updated is None:
        return problem(412, "changed-by-someone-else", current=notes.get(note_id).to_json())
    return updated.to_json(), 200, {"ETag": f'"{updated.version}"'}

How to check

A browser test types, waits 3 seconds and reloads, and the text is there. A second test changes the record from another session first. The editor shows the conflict, and neither change is lost.

For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers app interface playbook: https://agentwranglers.se/playbook/app-ui/forms/autosave.