Collaborative Authorship
Collaborative Authorship verifies a credential at the gate for team projects — proof without a phone call to the issuer.
The primitive.
Photographers request a proof and the holder answers from their wallet for team projects — the gate turns green on the predicate alone, with no personal data copied into your database.
Why this primitiveProof Presentation is right for team projects in Photography because the job is checking a claim at a gate — fast, offline-friendly, and revealing only the fact that matters instead of copying personal data into another database.
Pick how the agent runs.
Start here. It is the only mode that reliably fits a one-shot, 5-credit Lovable build, and you can swap the base URL for a real agent later without touching the UI.
No keys required.
The simulated agent runs entirely inside the app. Paste the prompt and build — nothing to configure, nothing to wait for.
Switch to Docker or Fly.io above when you want a real Cloud Agent.
The build prompt.
Paste into a fresh Lovable project. The prompt below is written for the Simulated agent mode. read the build strategy →
Build "Collaborative Authorship" as a ONE-SHOT Lovable build.
Single-page TanStack Start app. Cut scope ruthlessly.
CONCEPT
Photographers collaborating on projects can present verified contributions for shared credit and financial arrangements.
Discipline: Photography (team projects).
Recipe: Proof Presentation (verify without the middleman) as the single Hyperledger Identus primitive.
Why Identus: Proof Presentation is right for team projects in Photography because the job is checking a claim at a gate — fast, offline-friendly, and revealing only the fact that matters instead of copying personal data into another database.
LOVABLE BUDGET (HARD CAP: ONE-SHOT, ~5 CREDITS TOTAL):
This prompt MUST ship a working demo on the FIRST message with zero follow-ups.
- ONE TanStack Start app, ONE route (`src/routes/index.tsx`). No auth, no nav.
- ONE server module `src/lib/identus.functions.ts` with `createServerFn` wrappers.
- ONE client surface wired to it (form + result panel).
- NO database, NO Lovable Cloud, NO file uploads, NO extra integrations.
- Libraries: template defaults + `zod`. Nothing else.
- If a feature is not in the user flow below, do not build it.
IDENTUS PRIMER (assume the reader has never used Identus)
- Hyperledger Identus is open-source self-sovereign identity: an issuer signs a
W3C Verifiable Credential, a holder keeps it in their wallet, and a verifier
checks it without phoning the issuer.
- The Cloud Agent is a REST service. Everything below is plain HTTP + JSON.
Docs: https://identus.io/documentation/develop/ ·
https://github.com/hyperledger-identus/cloud-agent
- Identifiers are `did:prism:<hex>` — created unpublished, then published so
others can resolve them. Only a PUBLISHED DID carrying an `assertionMethod`
key can sign a credential.
- Agents talk to each other over DIDComm; a Mediator relays messages to wallets
that are not always online (https://github.com/hyperledger-identus/mediator).
- Browser/mobile holders use the TypeScript SDK
(https://github.com/hyperledger-identus/sdk-ts) — not needed for this build.
- Auth to the agent is the `apikey: <AGENT_API_KEY>` header. Server-side only.
RUNTIME CONVENTIONS (this template — follow exactly)
- Stack: TanStack Start v1 + React 19 + Vite 7 + Tailwind v4 (`src/styles.css`).
There is NO `src/App.tsx`, no react-router-dom, no `src/pages`.
- Routes live in `src/routes`; the home page is `src/routes/index.tsx` and uses
`createFileRoute("/")({ component: Page, head: () => ({ meta: [...] }) })`.
Never edit `src/routeTree.gen.ts`.
- Server logic: `import { createServerFn } from "@tanstack/react-start";` in a
client-safe module such as `src/lib/identus.functions.ts`. Shape is
`createServerFn({ method: "POST" }).inputValidator((d) => schema.parse(d)).handler(async ({ data }) => {...})`.
Call it from the client with `useServerFn(fn)` or directly inside an event handler.
- Routes and components import ONLY from `*.functions.ts` (and plain type modules) —
never from a `*.server.ts` file. Raw agent logic lives in `*.server.ts`, is
imported by the `.functions.ts` wrapper, and never reaches the client bundle.
- Read `process.env.AGENT_BASE_URL` / `process.env.AGENT_API_KEY` INSIDE the
handler — never at module scope (env is injected at call time).
- The server runtime is a Cloudflare-style Worker: use `fetch`, `crypto.randomUUID()`,
`Buffer`. No child_process, no sharp, no native modules.
- TypeScript runs with `exactOptionalPropertyTypes`: pass optional props as
`...(x ? { prop: x } : {})`, not `prop: x ?? undefined`.
- Toasts: `sonner` (`import { toast } from "sonner"`), and render `<Toaster />`
once in `src/routes/__root.tsx`. `@/hooks/use-toast` does NOT exist here.
- Colours come from semantic tokens in `src/styles.css` — no hardcoded
`text-white` / `bg-black` / `bg-[#hex]` in components.
- Give `src/routes/index.tsx` its own `head()` with a real title and description.
MODE — SIMULATED AGENT (no external service, no secrets)
Do NOT call a real Identus Cloud Agent. Implement `src/lib/identus.server.ts` as a
deterministic in-memory simulator that mirrors the Cloud Agent's REST shapes, and
import it from `src/lib/identus.functions.ts`. Derive ids from a hash of the input
so SSR and hydration agree (no Math.random, no Date.now in render).
FIXTURE SHAPES (return exactly these keys)
- DID:
{ "did": "did:prism:<64 hex>", "longFormDid": "did:prism:<64 hex>:<base64url>",
"status": "PUBLISHED",
"document": { "id": "did:prism:<64 hex>",
"verificationMethod": [{ "id": "#auth-1", "type": "JsonWebKey2020", "curve": "secp256k1" },
{ "id": "#assert-1", "type": "JsonWebKey2020", "curve": "secp256k1" }],
"authentication": ["#auth-1"], "assertionMethod": ["#assert-1"], "service": [] } }
- Connection:
{ "connectionId": "<uuid-like>", "state": "InvitationGenerated",
"invitation": { "id": "<uuid-like>",
"invitationUrl": "https://my.domain/path?_oob=<base64url json>" },
"theirDid": null }
Advance to "ConnectionResponseSent" (and fill `theirDid`) on the second poll.
- Credential record: (always include a `dob` claim so an age/ZK proof stays possible)
{ "recordId": "<uuid-like>", "protocolState": "OfferSent",
"claims": { "dob": "1994-05-02", ... },
"issuingDID": "did:prism:...", "credentialFormat": "JWT",
"credential": "<header>.<payload>.<signature>" } // three base64url parts, well-formed but unsigned
Advance OfferSent -> RequestReceived -> CredentialSent across polls.
- Presentation:
{ "presentationId": "<uuid-like>", "status": "RequestSent", "verified": false, "data": {} }
Advance RequestSent -> PresentationReceived -> PresentationVerified, then set
`verified: true` and `data` to the disclosed claims only.
UI REQUIREMENT
Show a small badge reading "Simulated agent — no cryptographic verification" so a
judge knows nothing is being faked silently. Keep every shape identical to the real
API so switching to a live agent is a one-line base-URL change.
MODE GOTCHAS
Never claim cryptographic verification in the copy while in simulated mode — label it clearly. Keep the simulator pure (no timers, no randomness that breaks re-renders) so SSR and hydration agree.
CLOUD AGENT API REFERENCE (everything you need — no other docs required)
All calls: base URL `AGENT_BASE_URL`, headers
`{ "content-type": "application/json", apikey: AGENT_API_KEY }`.
GET /_system/health -> { version } (use for a status pill)
POST /did-registrar/dids
body { documentTemplate: { publicKeys: [{ id, purpose: "authentication"|"assertionMethod", curve: "secp256k1" }], services: [] } }
-> { longFormDid, status: "CREATED" }
POST /did-registrar/dids/{didRef}/publications -> { scheduledOperation: { id, didRef } }
GET /did-registrar/dids/{didRef} -> { did, longFormDid, status: "CREATED"|"PUBLICATION_PENDING"|"PUBLISHED" }
GET /dids/{did} -> resolved DID document
(check `assertionMethod` is non-empty before offering the DID as an issuer)
POST /connections
body { label, goalCode: "connect", goal }
-> { connectionId, state: "InvitationGenerated", invitation: { invitationUrl, id } }
POST /connection-invitations
body { invitation: "<oob base64url string from the invitationUrl ?_oob= param>" }
GET /connections/{connectionId}
-> { state } : InvitationGenerated -> ConnectionRequestReceived -> ConnectionResponseSent
GET /connections -> { contents: [...] }
POST /issue-credentials/credential-offers
body { claims: { ... }, issuingDID, credentialFormat: "JWT", automaticIssuance: true,
connectionId? | (goalCode + goal for connectionless) }
-> { recordId, protocolState: "OfferSent", invitation?: { invitationUrl } }
GET /issue-credentials/records/{recordId}
-> { protocolState } : OfferSent -> RequestReceived -> CredentialSent, plus `credential` (JWT string)
GET /issue-credentials/records -> { contents: [...] }
POST /present-proof/presentations
body { connectionId, proofs: [], options: { challenge, domain },
claims: { "<attr>": {} } } // or anoncredPresentationRequest for ZK predicates
-> { presentationId, status: "RequestSent" }
GET /present-proof/presentations/{presentationId}
-> { status } : RequestSent -> PresentationReceived -> PresentationVerified | PresentationVerificationFailed
plus `data` (the disclosed claims)
Errors are RFC-7807 JSON: { status, title, detail }. Surface `detail` in the UI —
it names the real problem (unpublished DID, missing connectionId, bad apikey).
SERVER SNIPPET — Proof Presentation (verify a credential)
```ts
export const requestProof = createServerFn({ method: "POST" })
.inputValidator((d) => z.object({ connectionId: z.string(), attributes: z.array(z.string()) }).parse(d))
.handler(async ({ data }) => {
const base = process.env.AGENT_BASE_URL!.replace(/\/$/, "");
const res = await fetch(`${base}/present-proof/presentations`, {
method: "POST",
headers: { "content-type": "application/json", apikey: process.env.AGENT_API_KEY! },
body: JSON.stringify({
connectionId: data.connectionId,
proofs: [],
options: { challenge: crypto.randomUUID(), domain: "https://example.app" },
claims: Object.fromEntries(data.attributes.map((a) => [a, {}])),
}),
});
const rec = await res.json();
return { presentationId: rec.presentationId, state: rec.status };
});
```
Poll `GET /present-proof/presentations/{id}` until `PresentationVerified`, then
show a green/red gate. Reveal only the predicate you needed — never store the
holder's full credential.
USER FLOW (build exactly this, nothing more)
1. The verifier picks which attributes they demand (checkboxes).
2. Press "Request proof" -> server function creates the presentation request.
3. Poll until `PresentationVerified` and show a large PASS / FAIL gate.
4. List ONLY the disclosed attributes, with a note that nothing else was revealed.
DESIGN
Editorial, high-contrast, generous whitespace. One accent colour used sparingly.
Show the protocol honestly: render the state machine and the raw JSON envelope in
a collapsible panel so a judge can see the real Identus record. Truncate DIDs and
JWTs in prose (first 12 + last 6 chars) with the full value behind a copy button.
Mobile first — a judge will open this on a phone.
MARKET (for the pitch slide, not the UI)
TAM $3.0B — global fashion tech and photography software · SAM $600M — editorial and fashion photography tools · SOM $40M — fashion photographers and editorial producers
GOTCHAS (universal — apply to every agent call in this build)
- Only a PUBLISHED DID with an `assertionMethod` key can sign a credential
offer. Create -> publish -> wait for `PUBLISHED` before issuing, or you get a
cryptic 422/500. Before showing an issuer picker, resolve each DID with
`GET /dids/{did}` and offer ONLY those whose document exposes `assertionMethod`;
list the excluded ones with the reason instead of hiding them.
- Put a `dob` claim on every issued credential (ISO date). Without a date-of-birth
claim an age / zero-knowledge proof is impossible later.
- On a direct Fly deploy the agent serves at the app ROOT — strip any trailing
`/cloud-agent` from the base URL. On the local docker compose stack, KEEP it.
- Every issuance/presentation endpoint is asynchronous: you POST, then POLL the
record's `protocolState` (`OfferSent` -> `CredentialSent`,
`RequestSent` -> `PresentationVerified`). Never assume the POST finished the job.
- Connectionless issuance omits `connectionId` and needs a `goalCode`; if you
send neither a `connectionId` nor a `goalCode` you get "Missing connectionId".
- The human principal and any AI agent acting for them are DIFFERENT DIDs.
Compare principal to credential subject, agent to mandate subject — never cross them.
- Never print a raw DID, JWT or hash inline in prose: truncate (first 12 + last 6)
and keep the full value behind a copy button or in the raw JSON panel.
- First boot of a real agent migrates four databases: allow ~5 minutes and >= 4 GB
of memory before deciding it is broken.
- Never call the agent from the browser. Every fetch lives inside a
`createServerFn` handler so `AGENT_API_KEY` stays server-side.
REFERENCE MATERIAL (if you need more than the above)
- Identus docs: https://identus.io/documentation/develop/
- Cloud Agent (OpenAPI + compose examples): https://github.com/hyperledger-identus/cloud-agent
- TypeScript SDK (browser/wallet holders): https://github.com/hyperledger-identus/sdk-ts
- Mediator (DIDComm relay for offline wallets): https://github.com/hyperledger-identus/mediator
- Umbrella repo: https://github.com/hyperledger-identus/hyperledger-identus
- Reference console built with this stack: https://github.com/arunnadarasa/identus
- Full machine-readable brief for your own LLM: https://identusprompts.lovable.app/llms-full.txt
DELIVERABLE
A working single-page demo where the flow above completes end-to-end, plus a
one-paragraph README explaining which Identus primitive is used and how to point
the app at a real Cloud Agent.
Built for the Hyperledger Identus Catalyst — organised by StreetKode Fam during Indian Krump Festival 14.Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.