diff --git a/.env.example b/.env.example
index 491fdd6..6f19c90 100644
--- a/.env.example
+++ b/.env.example
@@ -171,10 +171,11 @@ R2_MAX_BYTES=10485760
# window.__CONFIG__ (see app/layout.tsx).
R2_ALLOWED_CONTENT_TYPES=image/png,image/jpeg,image/webp
# Attachments feature toggle — gates the assistant-ui ``
-# attachment UI. Default false; flip to "true" once every required
-# R2_* above is set. Frontend and backend must be in sync: if this
-# stays "false" the composer hides the attachment widget, but routes
-# still answer when called. Read in browser via window.__CONFIG__.
+# attachment UI AND the Settings → Account avatar upload (both backed by
+# R2). Default false; flip to "true" once every required R2_* above is
+# set. Frontend and backend must be in sync: if this stays "false" the
+# composer hides the attachment widget + the avatar upload is disabled,
+# but routes still answer when called. Read in browser via window.__CONFIG__.
ATTACHMENTS_ENABLED=false
diff --git a/app/api/avatar/presign/route.ts b/app/api/avatar/presign/route.ts
new file mode 100644
index 0000000..63320e4
--- /dev/null
+++ b/app/api/avatar/presign/route.ts
@@ -0,0 +1,91 @@
+import { NextResponse } from "next/server";
+import { randomBytes } from "node:crypto";
+
+import { R2NotConfiguredError, buildPublicUrl, presignPut } from "@/lib/r2/client";
+import { safeFilename } from "@/lib/attachments/keys";
+import { PresignBody } from "@/lib/attachments/validators";
+import { withAuth } from "@/lib/auth/with-auth";
+
+// ponytail: avatars go to R2 under the user's own path, NOT base64 into
+// user.image. A base64 data URL there flows through the memory auth-overlay
+// (mergeMemory) into every system block uncapped — issue #28's
+// 372K-token blow-up. This route hands back a presigned PUT + the public
+// URL; the client stores only the URL. No DB row (avatars aren't chat
+// attachments — no dedup / retention sweep needed).
+
+const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
+const ID_LEN = 12;
+
+function generateId(): string {
+ const bytes = randomBytes(ID_LEN);
+ let out = "";
+ for (let i = 0; i < ID_LEN; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
+ return out;
+}
+
+function maxBytes(): number {
+ const parsed = process.env.R2_MAX_BYTES ? Number(process.env.R2_MAX_BYTES) : NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 1024 * 1024;
+}
+
+function notConfiguredResponse(): NextResponse {
+ return NextResponse.json(
+ {
+ code: "AVATAR_UPLOADS_NOT_CONFIGURED",
+ message: "Set the R2_* env vars to enable avatar uploads (see docs/ATTACHMENTS.md).",
+ },
+ { status: 503 },
+ );
+}
+
+export const POST = withAuth(async (req, { user }) => {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ code: "BAD_REQUEST", error: "invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = PresignBody.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ code: "BAD_REQUEST", error: parsed.error.issues }, { status: 400 });
+ }
+
+ // ponytail: image/* only, and explicitly NOT svg — an SVG can carry
+ // inline