"Write today, forever"
Writer is an onchain writing platform that allows users to create, edit, and share writing entries stored on the blockchain with optional encryption for privacy.
Writer enables users to create "places" (smart contract-backed writers) where they can publish entries that are permanently stored onchain. Entries can be public for discovery or encrypted for private journaling. The platform emphasizes ownership, permanence, and privacy through cryptographic guarantees.
- Framework: Next.js 15 with React 19
- Language: TypeScript (strict mode)
- Styling: Tailwind CSS 4.0
- Auth: Privy (SMS, email, wallet login)
- State: TanStack React Query with polling
- Editor: TipTap (rich text/markdown)
- Blockchain: wagmi/viem for Ethereum
- Runtime: Bun
- Framework: Hono
- Validation: Zod
- Transaction Relay: Syndicate SDK
- Agent Payments: x402 for programmatic create/update/delete flows
- Database: PostgreSQL (Neon serverless)
- ORM: Drizzle
- Language: Solidity ^0.8.13
- Chain: Optimism (L2)
- Tooling: Foundry
- Runtime: Bun
- Purpose: Syncs onchain events to database via viem (catchup + WebSocket realtime)
- Runtime: Bun
- Purpose: x402-powered command line client for agents/scripts to list Places, create Places, create/edit/delete entries
writer/
├── packages/
│ ├── web/ # Next.js frontend
│ │ ├── src/
│ │ │ ├── app/ # Pages (home, explore, writer)
│ │ │ ├── components/ # React components
│ │ │ ├── hooks/ # Custom React hooks
│ │ │ └── utils/ # API, auth, encryption, signing
│ ├── server/ # Hono API server
│ │ └── src/
│ │ ├── server.ts # API endpoints
│ │ └── helpers.ts # Signature recovery
│ ├── db/ # Database schema & migrations
│ │ └── src/
│ │ ├── schema.ts # Drizzle schema
│ │ └── relations.ts # ORM relationships
│ ├── chain/ # Solidity smart contracts
│ │ └── src/
│ │ ├── WriterFactory.sol
│ │ ├── Writer.sol
│ │ └── WriterStorage.sol
│ ├── ingestor/ # Onchain event ingestor (viem)
│ ├── cli/ # x402 CLI + agent skill docs
│ └── utils/ # Shared ABIs and utilities
- Each writer is a smart contract pair (Writer + WriterStorage)
- Created via WriterFactory using CREATE2 (deterministic addresses)
- Has admin and manager roles for access control
- Can be marked public or private
- Writing content stored as "entries" within a writer
- Supports chunked storage for large content
- Tracked with both database ID and onChainId
- Onchain entry IDs may be
0; do not validate them as positive-only - All writes require EIP-712 signatures from the author
- Public entries: Stored as plaintext, discoverable on explore page
- Private entries: AES-GCM encrypted before storage
- Encryption key derived from wallet signature
- Format:
enc:v5:br:{base64(iv + ciphertext)}(versioned, Brotli compressed, encrypted)
- Public entries: IndexedDB (persistent)
- Private entries: In-memory only (security-first, cleared on logout)
Creates Writer + WriterStorage pairs with deterministic addresses.
Main logic contract with role-based access control:
createWithChunk()- Create entry with initial contentupdate()- Update existing entryremove()- Soft delete entry- All operations have signature variants (
*WithSig)
Holds entry state (content chunks, author, timestamps).
All protected routes check privy-id-token cookie via Privy server auth.
- Frontend signs EIP-712 typed data
- Backend validates signature and recovers signer
- Transaction relayed via Syndicate
- Database record created with pending status
- Ingestor updates status when confirmed onchain
Programmatic agent writes use x402 endpoints instead of Privy browser auth:
- Agent/CLI prepares request and x402 payment using the payer private key
- Entry create/update/delete also sign EIP-712 typed data with the same key
- Server verifies the x402 payer matches the requested admin or recovered signer
- Transaction is simulated, relayed through Syndicate, and recorded as
source: "x402" - Ingestor confirms the onchain event and updates indexed state
Important invariants:
- Place creation: x402 payer must equal the requested admin address.
- Entry create/update/delete: x402 payer must equal the recovered EIP-712 signer.
- Entry update/delete: recovered signer must match the existing entry author.
- x402 flows return pending transaction IDs before indexing completes.
GET /writer/public- List public writersGET /writer/:address- Get writer with entriesGET /.well-known/writer-agent.json- Machine-readable agent discovery manifestGET /.well-known/x402.json/GET https://api.writer.place/x402/capabilities- Machine-readable x402 pricing and write capabilitiesGET /writer/:address.md- Web Markdown URL for Place summaries and public entry linksGET /writer/:addresswithAccept: text/markdown- Content-negotiated Place MarkdownGET /writer/:address/:id.md- Web Markdown URL for public/plaintext entries with provenance frontmatter (returns 403 for encrypted entries)GET /writer/:address/:idwithAccept: text/markdown- Content-negotiated Markdown for public/plaintext entriesGET /docs.mdorGET /docswithAccept: text/markdown- Markdown API/platform docsGET /explore.mdorGET /explorewithAccept: text/markdown- Markdown public Place discoveryGET /tx/:id- Relay transaction status for CLI--waitconfirmationGET /openapi.json- OpenAPI 3.1 schema for public reads and x402 agent writesPOST /factory/create- Create new writer (Privy/frontend auth)POST /writer/:address/entry/createWithChunk- Create entry (Privy/frontend auth)POST /writer/:address/entry/:id/update- Update entry (Privy/frontend auth)POST /writer/:address/entry/:id/delete- Delete entry (Privy/frontend auth)
POST /x402/factory/create- Create Place; payer becomes admin and sole managerPOST /x402/writer/:address/entry/createWithChunk- Create entry with EIP-712CreateWithChunksignaturePOST /x402/writer/:address/entry/:id/update- Replace entry content with EIP-712UpdatesignaturePOST /x402/writer/:address/entry/:id/delete- Delete entry with EIP-712Removesignature
- Public agent guide:
packages/web/public/agents.md→https://writer.place/agents.md - Agent discovery manifest:
packages/web/src/app/.well-known/writer-agent.json/route.ts→https://writer.place/.well-known/writer-agent.json - Plain-text agent summary:
packages/web/public/agents.txt→https://writer.place/agents.txt - LLM summary:
packages/web/public/llms.txt→https://writer.place/llms.txt - Local agent skill:
packages/cli/skills/writer/SKILL.md - TypeScript SDK:
packages/sdk
Keep these files synchronized whenever x402 endpoints, CLI commands, payment/signature requirements, content encoding, or safety policy changes.
# Install dependencies
bun install
# Run all services
bun dev
# Run database migrations
bun db:migrate
# Format code
bun run formatNEXT_PUBLIC_BASE_URL- API server URLNEXT_PUBLIC_PRIVY_APP_ID- Privy app ID
DATABASE_URL- PostgreSQL connection stringSYNDICATE_PROJECT_ID- Syndicate project IDFACTORY_ADDRESS- WriterFactory contract addressX402_PAY_TO_ADDRESS- Address receiving x402 paymentsX402_NETWORK- CAIP-2 x402 payment network (defaulteip155:8453)X402_FACILITATOR_URL- x402 facilitator URLX402_PLACE_CREATE_PRICE- Price forPOST /x402/factory/create(default$0.01)X402_ENTRY_CREATE_PRICE- Price for x402 entry creation (default$0.002)X402_ENTRY_UPDATE_PRICE- Price for x402 entry updates (default$0.002)X402_ENTRY_DELETE_PRICE- Price for x402 entry deletion (default$0.001)
DATABASE_URL- PostgreSQL connection stringRPC_URL- Optimism HTTP RPC endpointWS_RPC_URL- Optimism WebSocket RPC endpointTARGET_CHAIN_ID- Chain ID to syncFACTORY_ADDRESS- WriterFactory contract addressOLD_FACTORY_ADDRESS- (optional) Legacy WriterFactory address list. Accepts one address, comma/whitespace-separated addresses, or a JSON string array.START_BLOCK- Initial block to sync from when no cursor existsHEALTH_PORT- (optional) Port for health check server (default 3001)
Auth uses a server-hint + client-reactive model:
layout.tsxcallsgetAuthHint()(cookie presence check —privy-id-token || privy-session) and passes the boolean throughProviders, which exposes it viaAuthHintContext.- Client components call
useIsLoggedIn()(inpackages/web/src/hooks/useIsLoggedIn.ts). The hook merges the SSR hint with Privy's live state:ready ? authenticated : hint. - Why
privy-session, not justprivy-id-token: the id-token cookie is short-lived (~1hr);privy-sessionis long-lived (~30d) and is what Privy uses to resurrect the id-token client-side. Checking both avoids the "sign in" → "write" flash when a logged-in user returns after the id-token expired. - Use
getAuthenticatedUser()only when you actually need a verified user object (e.g., foruser.wallet.addressserver-side). For UI "is this person logged in?" questions, usegetAuthHint()/useIsLoggedIn()— no network round-trip.
Privy useLogin caveat: Do NOT use useLogin({ onComplete }) for side effects like window.location.reload() — the onComplete callback can fire on mount when Privy auto-detects an existing session, causing infinite reload loops. Use usePrivy().login instead when you don't need the callback, or guard with a ref tracking explicit user interaction.
User-initiated writes (create/update/remove entries) sign EIP-712 payloads. The signing UX depends on wallet type:
- Privy embedded wallet (
wallet.walletClientType === "privy"): signs silently, no user prompt. Proceed with the optimistic flow immediately — clear input, show pending card, run the mutation. A loader here would flash unnecessarily and break the "instant" feel. - External wallets (MetaMask, WalletConnect, Coinbase, etc.): signing triggers a popup/app prompt the user must act on. Show a loader (e.g.,
CreateInput'sisLoadingoverlay) for the signing window only, so the user has visible feedback that we're waiting on their wallet.
Implementation pattern: track an isSigning state, set it true only when walletClientType !== "privy", wrap just the sign call in try/finally (not compression/encryption — those are fast). Writer creation (factoryCreate) does not sign, so no loader logic is needed there.
Writer and entry creations use TanStack Query's onMutate for optimistic insertion:
onMutateinserts a pending placeholder into the relevant cache (["get-writers", address]or["writer", writerAddress]) withcreatedAtHash: null.onErrorrestores the snapshot taken inonMutate.onSettledinvalidates to let the server row replace the optimistic one.- The UI renders a per-card spinner while
createdAtHashis null; a background poller waits for the indexer/ingestor to confirm. - Do not gate the insertion on the POST's 200 response (the old
onSuccesspattern). The chain is the source of truth; the POST is just "accepted for relay."
The ingestor-confirmation poller (refetchInterval on the writer list and writer page) MUST be gated on useIsMutating for the matching create mutation key. If polling is allowed to fire while /create or /createWithChunk is in flight, the server returns a list without the new row (API writes are synchronous, so it only lands after the POST resolves) and React Query replaces the optimistic cache with the server response — causing a visible flash where the pending card disappears and then reappears after onSettled invalidates.
Pattern (already applied in WriterList.tsx and app/writer/[address]/page.tsx):
const isCreatingWriter = useIsMutating({ mutationKey: ["create-from-factory"] }) > 0;
// ...
refetchInterval: isPolling && !isCreatingWriter ? POLLING_INTERVAL : false,const isCreatingEntry = useIsMutating({ mutationKey: ["create-with-chunk", address] }) > 0;
// ...
refetchInterval: shouldPoll && !isCreatingEntry ? 3000 : false,Writes are synchronous server-side, so onSettled's invalidation is the correct moment to pull server truth; polling only exists to await onchain ingestion populating createdAtHash / onChainId. Do not remove these gates when refactoring.
- Dual Contract Model: Separates logic (Writer) from state (WriterStorage)
- CREATE2 Addresses: Predictable addresses before deployment
- Signature-Based Auth: Users sign with existing wallets, no centralized keys
- Compression + Encryption: Reduces onchain storage costs
- Soft Deletes: Maintains blockchain history integrity (delete from the chain but visible in block history)
- Syndicate Relay: Abstracts transaction complexity from users
// packages/web/src/utils/signer.ts
signCreateWithChunk({ storageAddress, totalChunks, content, nonce })
signUpdate({ storageAddress, id, totalChunks, content, nonce })
signRemove({ storageAddress, id, nonce })// packages/web/src/utils/utils.ts
encryptContent(content, key) // Returns "enc:v5:br:..."
decryptContent(encrypted, key) // Returns plaintext# packages/cli
bun writer list --pk 0x... --json
bun writer create-place --pk 0x... --title "My Place" --wait --json
bun writer create-entry --pk 0x... --writer 0x... --content-file ./entry.md --wait --json
bun writer edit-entry --pk 0x... --writer 0x... --entry-id 1 --content-file ./entry.md --wait --json
bun writer delete-entry --pk 0x... --writer 0x... --entry-id 1 --wait --jsonThe CLI writes human-readable text by default. Pass --json to get exactly one JSON object on stdout; JSON-mode errors are JSON on stderr with a non-zero exit code. The CLI uses the production Writer API (https://api.writer.place) and Base x402 network (eip155:8453) directly; only the payer/author key is configurable via --pk or PRIVATE_KEY.
The CLI submits raw markdown/plaintext unless callers pre-encode or encrypt content. Do not use raw CLI publishing for secrets or private user data.
// packages/web/src/utils/api.ts
const client = hc<AppType>(BASE_URL)
client.writer[":address"].$get({ param: { address } })- writer: Contract address, title, admin, managers, privacy flag
- entry: Content entries linked to writers via storageAddress
- chunk: Content chunks for large entries
- syndicate_tx: Transaction tracking (pending → confirmed)
- user: User preferences (color)