Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f4c7c15
server: Add manual credit granting on customer meters
strandhvilliam Oct 13, 2026
eca6325
server: Make credit customer meter endpoint public, regenerate client…
strandhvilliam Oct 13, 2026
5bc7b5c
server: Don't treat user-ingested meter.credited events as credit rep…
strandhvilliam Oct 13, 2026
a6ffe1d
docs: Drop credit customer meter page from navigation
strandhvilliam Oct 13, 2026
274391a
server: Accept external_customer_id when crediting customer meters
strandhvilliam Oct 13, 2026
aaf47e9
server: Reject credit requests with both customer_id and external_cus…
strandhvilliam Oct 13, 2026
4a4a8b0
server: Return the credit event, dedupe on conflict, drop config-as-c…
strandhvilliam Oct 13, 2026
68b8a4e
server: Move ConfigAsCodeNotEnabled back into the declarative config …
strandhvilliam Oct 13, 2026
df27ea6
server: Accept external_meter_id when crediting customer meters
strandhvilliam Oct 13, 2026
5c9eee5
clients/web: Add manual credit granting to customer meters
strandhvilliam Oct 13, 2026
44cf5a4
clients/web: Add credits from customer usage table rows
strandhvilliam Oct 13, 2026
f5c0882
clients/web: Simplify the add credits panel
strandhvilliam Oct 13, 2026
b04dfb3
clients/web: Tidy the add credits panel
strandhvilliam Oct 13, 2026
ba16930
clients/web: Name the duplicate credit check in the add credits panel
strandhvilliam Oct 13, 2026
ad81453
clients/web: Tighten the add credits panel UX
strandhvilliam Oct 13, 2026
b7cbc73
clients/web: Keep the add credits summary a fixed height
strandhvilliam Oct 13, 2026
51b7f8e
clients/web: Name the customer in the add credits panel
strandhvilliam Oct 13, 2026
0897821
modify customer-meter header aligment
strandhvilliam Oct 13, 2026
c1fd4e8
clients/web: Right-align the add credits panel buttons
strandhvilliam Oct 13, 2026
d72626f
Poll for changes
strandhvilliam Oct 14, 2026
a8af20f
clients/web: Match the usage table's subscription and wait for pricin…
strandhvilliam Oct 14, 2026
bdd5a12
clients/web: Wrap the customer meter toolbar and keep the customer fa…
strandhvilliam Oct 14, 2026
73c4fea
sdk: Regenerate for crediting customer meters
strandhvilliam Oct 13, 2026
66661c7
polar: Grant meter credits through the runtime actor
strandhvilliam Oct 13, 2026
3f756b2
demo-app: Reward a survey answer with free tool calls
strandhvilliam Oct 14, 2026
0da7fe6
Update clients/apps/demo-app/polar.ts
strandhvilliam Oct 14, 2026
7adafa2
server: Describe the credit flow to MCP agents
strandhvilliam Oct 14, 2026
3118202
server: Write the credit tool descriptions for MCP search
strandhvilliam Oct 14, 2026
1e8dd25
server: Expose credit granting to MCP like the other tools
strandhvilliam Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions clients/apps/demo-app/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
callTool,
createProCheckout,
ensureFreePlan,
grantSurveyCredits,
isPro,
polar,
purchaseTopUp,
Expand Down Expand Up @@ -66,6 +67,16 @@ export async function checkUpgraded() {
return isPro(externalId)
}

export async function claimSurveyCredits(answer: string) {
const externalId = await getExternalCustomerId()
if (!externalId) redirect('/')
if (typeof answer !== 'string' || answer.trim() === '') {
throw new Error('Missing survey answer')
}

await grantSurveyCredits(externalId, answer.trim().slice(0, 1000))
}

export async function topUp(pack: TopUp) {
const externalId = await getExternalCustomerId()
if (!externalId) redirect('/')
Expand Down
112 changes: 109 additions & 3 deletions clients/apps/demo-app/app/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

import { useEffect, useRef, useState } from 'react'
import type { getTopUps } from '../polar'
import { call, checkUpgraded, getBalance, topUp, upgrade } from './actions'
import {
call,
checkUpgraded,
claimSurveyCredits,
getBalance,
topUp,
upgrade,
} from './actions'
import { CodePanel, type Outcomes } from './code-panel'
import type { HighlightedSteps } from './highlight'
import type { Step } from './steps'
Expand Down Expand Up @@ -39,6 +46,17 @@ const entriesOf = (
pending,
}))

// Customers who claimed the survey credits: { [externalCustomerId]: true }
const SURVEY_KEY = 'survey_claimed'

const readSurveyClaims = (): Record<string, boolean> => {
try {
return JSON.parse(localStorage.getItem(SURVEY_KEY) ?? '{}')
} catch {
return {}
}
}

const readHistory = (): History => {
try {
return JSON.parse(localStorage.getItem(HISTORY_KEY) ?? '{}')
Expand All @@ -64,7 +82,7 @@ const timeAgo = (at: number, now: number) => {
const countOf = (event: { shiftKey: boolean; metaKey: boolean }) =>
event.shiftKey ? 100 : event.metaKey ? 10 : 1

type Modal = 'paywall' | 'upgrading' | 'upgraded' | 'topup'
type Modal = 'paywall' | 'upgrading' | 'upgraded' | 'topup' | 'survey'

type TopUpPack = Awaited<ReturnType<typeof getTopUps>>[number]

Expand Down Expand Up @@ -109,6 +127,11 @@ export function Demo({
const [pack, setPack] = useState(topUps[0]?.id)
const [purchasing, setPurchasing] = useState(false)
const [purchaseError, setPurchaseError] = useState<string>()
const [surveyAnswer, setSurveyAnswer] = useState('')
const [claiming, setClaiming] = useState(false)
const [claimError, setClaimError] = useState<string>()
const [surveyClaimed, setSurveyClaimed] = useState(true)
const [paywallServer, setPaywallServer] = useState<Server>()
const [balance, setBalance] = useState<number>()
const [pristine, setPristine] = useState(false)
const [syncStartedAt, setSyncStartedAt] = useState<number>()
Expand Down Expand Up @@ -163,6 +186,7 @@ export function Demo({
),
)
setHistoryLoaded(true)
setSurveyClaimed(readSurveyClaims()[externalCustomerId] === true)
}, [externalCustomerId])

useEffect(() => {
Expand Down Expand Up @@ -233,6 +257,7 @@ export function Demo({

if (result.paywall) {
setEntries((entries) => entries.filter((entry) => entry.at !== at))
setPaywallServer(server)
setModal('paywall')
return
}
Expand Down Expand Up @@ -271,6 +296,42 @@ export function Demo({
setPristine(false)
}

const openSurvey = () => {
setClaimError(undefined)
setModal('survey')
}

const onClaimSurvey = async (event: React.FormEvent) => {
event.preventDefault()
setClaiming(true)
setClaimError(undefined)

try {
await claimSurveyCredits(surveyAnswer)
} catch {
setClaimError('Something went wrong. Please try again.')
return
} finally {
setClaiming(false)
}

try {
localStorage.setItem(
SURVEY_KEY,
JSON.stringify({ ...readSurveyClaims(), [externalCustomerId]: true }),
)
} catch {}
setSurveyClaimed(true)
setActive({ step: 'grant' })
setOutcome(
'grant',
`grant('tool_call', 50, { idempotencyKey: 'survey:…' }) → credited`,
)
setModal(undefined)
setSyncStartedAt(Date.now())
setPristine(false)
}

const selectedPack = topUps.find((topUp) => topUp.id === pack)
const disabled = calling || !pristine
const syncSeconds =
Expand Down Expand Up @@ -331,7 +392,8 @@ export function Demo({
<dialog
ref={dialog}
onCancel={(event) => {
if (modal === 'upgrading' || purchasing) event.preventDefault()
if (modal === 'upgrading' || purchasing || claiming)
event.preventDefault()
}}
onClose={() => setModal(undefined)}
>
Expand All @@ -349,12 +411,56 @@ export function Demo({
Get Pro for {proPrice}
</button>
</form>
{paywallServer === 'builtin' && !surveyClaimed && (
<button className="link" onClick={openSurvey}>
Or get 50 tool calls for free
</button>
)}
<button className="ghost" onClick={() => setModal(undefined)}>
Not now
</button>
</div>
</div>
)}
{content === 'survey' && (
<form className="card" onSubmit={onClaimSurvey}>
<div>
<h1>Get 50 free tool calls</h1>
<p className="muted text-pretty">
Tell us what you're building. One reward per account.
</p>
</div>
<textarea
value={surveyAnswer}
onChange={(event) => setSurveyAnswer(event.target.value)}
placeholder="An agent that triages support tickets…"
aria-label="What are you building?"
maxLength={1000}
rows={3}
required
autoFocus
disabled={claiming}
/>
{claimError && <p className="error">{claimError}</p>}
<div className="actions">
<button
type="submit"
className="primary"
disabled={claiming || surveyAnswer.trim() === ''}
>
{claiming ? 'Adding tool calls…' : 'Claim 50 tool calls'}
</button>
<button
type="button"
className="ghost"
disabled={claiming}
onClick={() => setModal(undefined)}
>
Not now
</button>
</div>
</form>
)}
{content === 'topup' && (
<div className="card">
<div>
Expand Down
13 changes: 11 additions & 2 deletions clients/apps/demo-app/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ p {
0 12px 32px -12px #0000001f;
}

input {
input,
textarea {
height: 40px;
padding: 0 12px;
border: 0;
Expand All @@ -81,7 +82,15 @@ input {
transition: box-shadow 0.2s;
}

input:focus {
textarea {
height: auto;
padding: 10px 12px;
resize: none;
text-align: left;
}

input:focus,
textarea:focus {
outline: none;
box-shadow:
0 0 0 1px var(--accent),
Expand Down
21 changes: 20 additions & 1 deletion clients/apps/demo-app/app/steps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Code shown in the side panel, one step per part of the demo. Lines ending in
// `// !` are emphasized, and the marker is stripped before highlighting.

export type Step = 'config' | 'balance' | 'builtin' | 'custom'
export type Step = 'config' | 'balance' | 'builtin' | 'custom' | 'grant'

export type StepFile = { name: string; code: string }

Expand Down Expand Up @@ -142,6 +142,25 @@ await customer.track('tool_call', { // !
server: 'custom', // !
success: true,
duration_ms: 420,
})`,
},
],
},
{
id: 'grant',
label: 'Grant',
title: 'Grant credits without a purchase',
description:
'Reward an action with credits straight from your server. The idempotency key makes it once per customer, however many times it is claimed.',
files: [
{
name: 'polar.ts',
code: `const customer = polar.actor({ externalCustomerId: userId }) // !

await saveSurveyAnswer(userId, answer)

await customer.grant('tool_call', 50, { // !
idempotencyKey: \`survey:\${userId}\`, // !
})`,
},
],
Expand Down
16 changes: 16 additions & 0 deletions clients/apps/demo-app/polar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ export const purchaseTopUp = async (externalId: string, topUp: TopUp) => {
return { error: undefined }
}

export const SURVEY_CREDITS = 50

export const grantSurveyCredits = async (
externalId: string,
answer: string,
) => {
log('Survey answer submitted')

// Keyed per customer, so claiming the survey again doesn't grant twice
await polar
.actor({ externalCustomerId: externalId })
.grant('tool_call', SURVEY_CREDITS, {
idempotencyKey: `survey:${externalId}`,
})
}

export const callTool = async (
externalId: string,
server: 'builtin' | 'custom',
Expand Down
Loading
Loading