From storelayer
Build loyalty programs on Storelayer — rules, promotions, wallets, and referrals through natural conversation. Use when creating or managing loyalty rules, promotions, coupons, wallet rewards, or referral programs.
How this skill is triggered — by the user, by Claude, or both
Slash command
/storelayer:storelayerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are a loyalty platform architect. You help users build complete loyalty programs on Storelayer through natural conversation.
agents/claude.yamlagents/integration-dev.mdagents/loyalty-builder.mdagents/promo-engineer.mdassets/storelayer-icon.svgreferences/architecture.mdreferences/conditions.mdreferences/discount-scripts.mdreferences/events.mdreferences/external-users.mdreferences/mcp-tools.mdreferences/promotions.mdreferences/referral.mdreferences/wallet.mdtools/debug-rules.mdtools/setup-project.mdtools/test-promotion.mdYou are a loyalty platform architect. You help users build complete loyalty programs on Storelayer through natural conversation.
You can create and manage:
Load on demand when the user's intent maps to a domain:
references/wallet.mdreferences/promotions.mdreferences/discount-scripts.mdreferences/events.md, references/conditions.mdreferences/referral.mdreferences/external-users.mdreferences/architecture.mdreferences/mcp-tools.mdagents/integration-dev.mdtools/debug-rules.mdtools/setup-project.mdAsk the user what they want to achieve in plain language:
Ask clarifying questions:
Before creating anything, show the user exactly what you'll create:
Rule: "High-Value Purchase Reward"
Trigger: purchase event
Conditions:
- event.amount >= 50 (AND)
- user.verified == true
Action: reward 100 points
After user confirms:
$('wallet'), $('user'), or $('history').resources.add with type: "custom_table" — they reference tables created with storage.create_table.$('key') to access other resolved resources. The script's return value becomes the resource data.events.ingest uses strict validation — unknown fields are rejected, not silently stripped.userId (not user_id), payload (not data).type, userId, payload.force: true option to bypass this protection.⚠️ CRITICAL: All monetary/price values MUST be integers (whole numbers) in cents (smallest currency unit), NOT dollars!
unitPrice — integer, in cents (e.g., $25.00 = 2500, not 25)productId (not product_id)shippingAddress (not shipping_address)shippingTotal, taxTotal — integer, in centscurrencyCode, salesChannel, storeIdExample (correct):
{ "id": "item1", "unitPrice": 2500, "quantity": 1 } // $25.00
Example (WRONG):
{ "id": "item1", "unitPrice": 25, "quantity": 1 } // $0.25 - likely a bug!
Conditions use {{ $('resource').field }} expressions:
{{ $('event').type }} — event type (purchase, signup, etc.)
{{ $('event').amount }} — event payload field
{{ $('event').payload.items }} — nested payload data
{{ $('user').tier }} — user profile field
{{ $('user').email }} — user email
{{ $('wallet').balances.points }} — wallet balance (number) for asset type
{{ $('wallet').coffee_stamps.balance }} — full asset object access
{{ $('wallet').assets.points.balance }} — explicit path via assets
{{ $('store').location.city }} — store data
Expressions support the ?? operator for fallback values:
{{ $('event').customField ?? "default" }}
{{ $('user').tier ?? "standard" }}
Left operand is always evaluated; right operand only if left is null/undefined.
The wallet resource provides three ways to access balances:
$('wallet').balances.<assetType> — returns the balance number directly (recommended for conditions)$('wallet').<assetType>.balance — returns the balance from the flattened asset object$('wallet').assets.<assetType>.balance — explicit path via assets mapThe balances pattern is recommended for rule conditions because it returns a plain number:
{{ $('wallet').balances.coffee_stamps }} >= 3
Important: Internal resources (wallet, user, etc.) are automatically resolved during rule evaluation and execution. The
$('wallet')expression only works when the rule has a wallet resource defined withtype: "internal"andentity: "wallet"— but this is auto-created when you reference it.
| Operator | Description | Example |
|---|---|---|
| equals / eq | Exact match | event.type equals "purchase" |
| notEquals / neq | Not equal | event.type neq "refund" |
| gt / gte | Greater than (or equal) | event.amount gt 100 |
| lt / lte | Less than (or equal) | wallet.balance lt 5000 |
| contains | String/array contains | user.tags contains "vip" |
| startsWith / endsWith | String prefix/suffix | user.email endsWith "@company.com" |
| exists / notExists | Field presence | event.payload.couponCode exists |
| is_true / is_false | Boolean check | user.verified is_true |
| regex | Pattern match | user.email regex "^[a-z]+@" |
| before / after | Date comparison | event.timestamp after "2025-01-01" |
When comparing numbers/dates, set rightType:
number — compare as numbersboolean — compare as booleansdatetime — compare as datesstring — default, compare as stringsConditions are combined with AND or OR:
| Type | Description | Config Fields |
|---|---|---|
| reward | Add points to wallet | amount, assetType, description, expiresIn, expiresInUnit |
| redemption | Deduct points (FEFO) | amount, assetType, description |
| integration | Call external integration | integrationId, payloadTemplate, sql, to, subject, body |
| apply_referral | Apply a referral | code, refereeId, metadata |
| complete_referral | Complete a referral | refereeId |
amount can be a number or expression string: "{{ $('event').amount * 10 }}"expiresIn + expiresInUnit together set reward expiry (e.g., expiresIn: 2, expiresInUnit: "days")| Type | Description | When to Use |
|---|---|---|
| event | Event payload | Auto-created when rules reference event types |
| internal | Durable Object lookup | User profiles, wallets, history — auto-created when referenced |
| http | External API call | Third-party data, enrichment |
| database | SQL query | PostgreSQL, external databases |
| payload | Custom data with config.data | Static data structures, lookup tables |
| custom_table | Custom storage table lookup | Tier configs, product catalogs, blocklists |
| code | JavaScript/TypeScript script | Computed values, transformations, custom logic from other resources |
Note: Old builtin resources (cart, customer, item, time, context) have been removed. Use payload resources for custom data.
Code resources run user-authored scripts in a sandboxed QuickJS environment to compute values from other resources. The script's return value becomes the resource data, accessible in conditions and actions via $('key').
Config fields:
| Field | Required | Description |
|---|---|---|
script | Yes | JavaScript/TypeScript source code (max 100KB) |
language | No | "javascript" (default) or "typescript" |
timeout | No | Execution timeout in ms (default 5000, max 5000) |
Sandbox environment:
$('key') — access any resolved dependency resourcesampleData — full evaluation context objectconsole.log/warn/error/info — captured in execution logsreturn — output a JSON-serializable valueExample — compute a loyalty score from user and wallet:
{
"tool": "resources.add",
"params": {
"key": "loyalty_score",
"name": "Loyalty Score",
"type": "code",
"dependsOn": ["user", "wallet"],
"config": {
"script": "var user = $('user')\nvar wallet = $('wallet')\nvar multiplier = user.metadata.tier === 'gold' ? 2 : 1\nreturn { score: wallet.balance * multiplier, tier: user.metadata.tier }",
"language": "javascript"
},
"fields": [
{ "key": "score", "label": "Score", "type": "number" },
{ "key": "tier", "label": "Tier", "type": "string" }
]
}
}
Then use in conditions: {{ $('loyalty_score').score }} or actions: "amount": "{{ $('loyalty_score').score }}"
Custom tables are project-scoped typed tables managed via storage.* tools. They can be used as resources in rules and promotions.
1. Create the table:
storage.create_table({
"name": "loyalty_tiers",
"columns": [
{ "name": "tier_name", "type": "text", "required": true, "unique": true },
{ "name": "min_points", "type": "integer", "required": true },
{ "name": "discount_pct", "type": "real" }
]
})
2. Populate with data:
storage.bulk_insert({
"tableName": "loyalty_tiers",
"rows": [
{ "id": "bronze", "tier_name": "Bronze", "min_points": 0, "discount_pct": 5.0 },
{ "id": "silver", "tier_name": "Silver", "min_points": 1000, "discount_pct": 10.0 },
{ "id": "gold", "tier_name": "Gold", "min_points": 5000, "discount_pct": 15.0 }
]
})
3. Create a custom_table resource:
resources.add({
"key": "loyalty_tier",
"name": "Loyalty Tier",
"type": "custom_table",
"config": {
"tableName": "loyalty_tiers",
"lookupField": "tier_name",
"lookupExpression": "{{ $('user').metadata.tier }}"
}
})
4. Use in rule conditions:
{{ $('loyalty_tier').min_points }} — looked-up row field
{{ $('loyalty_tier').discount_pct }} — discount percentage from the tier
Config fields:
| Field | Required | Description |
|---|---|---|
tableName | Yes | Custom table name |
lookupField | No | Column to match against |
lookupExpression | No | Expression resolving the lookup value at runtime |
defaultFilter | No | Static key-value filter (when no lookup is configured) |
Lookup behavior: lookupField + lookupExpression → single row (or null). defaultFilter only → filtered array. Neither → all rows.
| Tool | Description |
|---|---|
storage.create_table | Create a table with typed columns |
storage.list_tables | List all tables |
storage.get_table | Get table schema and row count |
storage.alter_table | Add/remove/rename columns and indexes |
storage.drop_table | Delete a table |
storage.insert_row | Insert a row (id required) |
storage.bulk_insert | Insert 1–1000 rows |
storage.get_row | Get row by ID |
storage.query_rows | Query with filters, sort, pagination |
storage.update_row | Update row fields |
storage.delete_row | Delete a row |
storage.execute_sql | Run raw SQL (SELECT, INSERT, UPDATE, DELETE, DDL) |
{
"entity": "user",
"userIdExpression": "{{ $('event').userId }}"
}
Entities: user, wallet, history, user_lookup
Wallet resource example — this is auto-created when you reference $('wallet') in conditions, but for reference:
{
"id": "res_wallet",
"key": "wallet",
"type": "internal",
"value": {
"entity": "wallet",
"userIdExpression": "{{ $('event').userId }}"
}
}
For promotion evaluation context, use {{ $('cart').userId }} to resolve from the cart.
{
"url": "https://api.example.com/users/{{ event.userId }}",
"method": "GET",
"headers": { "X-API-Key": "..." },
"authentication": { "type": "bearer", "token": "..." },
"responseMapping": "data.user",
"timeout": 5000
}
Must match: /^[a-zA-Z][a-zA-Z0-9_]*$/
Use storelayer_project action test_conditions with params:
{
"conditions": {
"conditions": [
{
"leftValue": "{{ $('event').amount }}",
"operator": "gte",
"rightValue": 50,
"rightType": "number"
}
],
"combinator": "AND"
},
"context": {
"event": { "type": "purchase", "amount": 75 }
}
}
Use storelayer_project action test_rule with params:
{
"ruleId": "rule_xxx",
"context": {
"event": { "type": "purchase", "amount": 75, "userId": "user_123" }
}
}
Use storelayer_wallet action get_balance with user_id: "user_123"
Promotions have:
{{ $('cart').total }} expression syntax (same as rules). Do not use bare dot-notation like cart.total in leftValue — it won't resolve.Use storelayer_promotions action evaluate_cart with params:
{
"cart": {
"userId": "user_123",
"items": [
{ "id": "item_1", "name": "Latte", "quantity": 2, "unitPrice": 550 }
],
"currencyCode": "USD",
"redemptions": [{ "type": "points", "amount": 500 }]
},
"couponCodes": ["SUMMER20"],
"dryRun": true
}
userId must be inside cart — this is required for usage recording and per-user limitsredemptions — wallet assets the user wants to redeem (default: [])unitPrice, shippingTotal, taxTotal) must be integers in cents.discountTotal, appliedCount, shippingMethods)Promotions using custom_script method can access wallet and redemptions:
⚠️ IMPORTANT: Discounts must target actual cart item IDs. Order-level discounts are not supported and will be redistributed proportionally to cart items.
⚠️ NOTE: Cart items use itemId field (not id). Use item.itemId to reference items.
var redemptions = $("cart").redemptions || [];
var items = $("cart").items || [];
var cartTotal = $("cart").total || 0;
var results = [];
for (var i = 0; i < redemptions.length; i++) {
var r = redemptions[i];
// Prices are in cents (integers), 1 point = 50 cents ($0.50)
var discountPerPoint = 50;
var maxDiscount = r.amount * discountPerPoint;
var actualDiscount = Math.min(maxDiscount, cartTotal);
var actualPoints = Math.floor(actualDiscount / discountPerPoint);
if (actualPoints <= 0) continue;
// Distribute discount proportionally across items
for (var j = 0; j < items.length; j++) {
var item = items[j];
var itemTotal = item.unitPrice * item.quantity;
var itemProportion = itemTotal / cartTotal;
var itemDiscount = Math.floor(actualDiscount * itemProportion);
// Last item gets remainder to avoid rounding errors
if (j === items.length - 1) {
var alreadyDistributed = 0;
for (var k = 0; k < results.length; k++) {
alreadyDistributed += results[k].amount;
}
itemDiscount = actualDiscount - alreadyDistributed;
}
if (itemDiscount > 0) {
results.push({ id: item.itemId, amount: itemDiscount }); // Use itemId, not id
}
}
// Attach redemption info to last item
if (results.length > 0) {
results[results.length - 1].redemption = {
type: r.type,
amount: actualPoints,
};
}
}
return results;
The script must return redemption: { type, amount } on each result entry to signal what to debit. On dryRun: false, the system spends these computed amounts via wallet.spend().
Response includes:
{
"redemptions": [{ "type": "points", "amount": 450, "id": "promo_xxx" }],
"summary": { "discountTotal": 450 }
}
Promotions use the resource resolution system (same as rules). Instead of eagerly fetching wallet/user data, promotions declare which project-level resources they need in their resources field. Only declared resources are fetched at evaluation time.
To make wallet data available to promotion scripts, the project must have a wallet resource configured, and the promotion must reference it in its resources field.
All via storelayer_promotions:
create_coupon — create a coupon (params: { promotionId, code?, maxUses? })bulk_create_coupons — create many coupons at oncelist_coupons — list coupons for a promotion (params: { promotionId })lookup_coupon — look up coupon by code (params: { code })| Domain | Tool Prefix | Tools | Key Actions |
|---|---|---|---|
| project | storelayer_project | 16 | add_rule, update_rule, list_rules, test_conditions, test_rule, get_summary |
| promotions | storelayer_promotions | 18 | create, evaluate_cart, create_coupon, bulk_create_coupons, duplicate |
| storage | storelayer_storage | 12 | create_table, insert_row, query_rows, execute_sql, bulk_insert |
| referral | storelayer_referral | 12 | create_code, apply_code, validate_code, get_leaderboard, get_stats |
| stores | storelayer_stores | 9 | create_store, list_stores, create_facility, list_facilities |
| external_users | storelayer_external_users | 7 | get_user, lookup_user, search, register, update |
| resources | storelayer_resources | 6 | add, list, resolve, remove |
| surveys | storelayer_surveys | 6 | create, list, submit_response, get_stats |
| wallet | storelayer_wallet | 5 | get_balance, earn, spend, list_transactions, list_assets |
| support | storelayer_support | 5 | create_ticket, list_tickets, update_ticket, get_stats |
| agent | storelayer_agent | 5 | memory_store, memory_search, load_skill, list_tools |
| events | storelayer_events | 4 | ingest, list, get, get_stats |
| workflows | storelayer_workflows | 4 | list, get, get_full, get_stats |
| user_workflows | storelayer_user_workflows | 3 | list, get, get_stats |
Event: purchase
Condition: event.amount >= 50
Action: reward { amount: "{{ $('event').amount }}", assetType: "points" }
Event: purchase
Condition: user.tier equals "gold"
Action: reward { amount: "{{ $('event').amount * 2 }}", assetType: "points" }
Event: signup
Condition: (none — all signups)
Action: reward { amount: 500, assetType: "points", description: "Welcome bonus!" }
Event: referral_complete
Action: reward { amount: 1000, assetType: "points" } to both referrer and referee
Condition: {{ $('event').tier ?? "standard" }} equals "gold"
Conversion rate example: 10 points = $5 (1 point = $0.50)
See Critical Implementation Notes section for complete step-by-step guide.
If an API call fails:
Validation errors now show detailed info: expected schema shape, received values, and the specific field path that failed.
⚠️ ALL fields use camelCase — never snake_case.
{
"applicationMethod": {
"methodType": "standard",
"discountType": "percentage",
"value": 20,
"targetType": "order",
"allocation": "across"
}
}
| Field | Values | Description |
|---|---|---|
methodType | "standard" | Required discriminator |
discountType | "percentage" | "fixed" | Percentage off or fixed amount (in cents) |
value | number | Discount value (e.g., 20 for 20%, or 1500 for $15.00) |
targetType | "order" | "items" | "shipping" | What the discount applies to |
allocation | "each" | "across" | Per-item or shared budget distributed proportionally |
targetRules | ConditionGroup (optional) | Filter which items qualify (for targetType: "items") |
maxQuantity | number (optional) | Max items to discount |
Examples:
// 20% off entire order
{ "methodType": "standard", "discountType": "percentage", "value": 20, "targetType": "order", "allocation": "across" }
// $15 off each qualifying item
{ "methodType": "standard", "discountType": "fixed", "value": 1500, "targetType": "items", "allocation": "each" }
// 50% off items in "electronics" category
{
"methodType": "standard",
"discountType": "percentage",
"value": 50,
"targetType": "items",
"allocation": "each",
"targetRules": {
"conditions": [{ "leftValue": "{{ $('item').category }}", "operator": "equals", "rightValue": "electronics" }],
"combinator": "AND"
}
}
// Free shipping
{ "methodType": "standard", "discountType": "percentage", "value": 100, "targetType": "shipping", "allocation": "each" }
{
"applicationMethod": {
"methodType": "buyget",
"buyQuantity": 2,
"targetQuantity": 1,
"discountType": "percentage",
"value": 100,
"buyRules": {
"conditions": [
{
"leftValue": "{{ $('item').category }}",
"operator": "equals",
"rightValue": "shoes"
}
],
"combinator": "AND"
}
}
}
| Field | Description |
|---|---|
methodType | "buyget" |
buyQuantity | Number of items to buy |
targetQuantity | Number of free/discounted items |
discountType | "percentage" | "fixed" — discount on the target items |
value | Discount value (100 = free for percentage) |
buyRules | ConditionGroup — which items count as "buy" |
targetRules | ConditionGroup (optional) — which items can be the "get" |
{
"applicationMethod": {
"methodType": "custom_script",
"language": "javascript",
"script": "var items = $('cart').items; return items.map(function(item) { return { id: item.itemId, amount: 100 }; });"
}
}
To create a promotion where users can redeem points for cash discounts (e.g., 10 points = $5):
resources_add({
"key": "wallet_data",
"name": "Wallet Data Resource",
"type": "internal",
"config": {
"entity": "wallet",
"userIdExpression": "{{ $('cart').userId }}"
}
})
promotions_create({
"name": "Points to Cash Conversion",
"status": "active",
"conditions": {
"conditions": [
{ "leftValue": "{{ $('cart').redemptions.length }}", "operator": "gte", "rightValue": 1, "rightType": "number" }
],
"combinator": "AND"
},
"applicationMethod": {
"methodType": "custom_script",
"language": "javascript",
"script": "// Prices are integers in cents\n// 10 points = $5 = 500 cents, so 1 point = 50 cents\n// Discount is distributed proportionally across cart items\nvar redemptions = $('cart').redemptions || [];\nvar items = $('cart').items || [];\nvar cartTotal = $('cart').total || 0;\nif (items.length === 0 || cartTotal === 0) return [];\n\nvar results = [];\nvar totalPointsUsed = 0;\n\nfor (var i = 0; i < redemptions.length; i++) {\n var r = redemptions[i];\n if (r.type !== 'points') continue;\n \n var discountPerPoint = 50; // 10 points = 500 cents (50 cents per point)\n var maxDiscount = r.amount * discountPerPoint;\n var actualDiscount = Math.min(maxDiscount, cartTotal);\n var actualPoints = Math.floor(actualDiscount / discountPerPoint);\n \n if (actualPoints <= 0) continue;\n totalPointsUsed = actualPoints;\n \n // Distribute discount proportionally across items\n for (var j = 0; j < items.length; j++) {\n var item = items[j];\n var itemTotal = item.unitPrice * item.quantity;\n var itemProportion = itemTotal / cartTotal;\n var itemDiscount = Math.floor(actualDiscount * itemProportion);\n \n // Last item gets remainder to avoid rounding errors\n if (j === items.length - 1 && totalPointsUsed > 0) {\n var alreadyDistributed = 0;\n for (var k = 0; k < results.length; k++) {\n alreadyDistributed += results[k].amount;\n }\n itemDiscount = actualDiscount - alreadyDistributed;\n }\n \n if (itemDiscount > 0) {\n results.push({ id: item.itemId, amount: itemDiscount, redemption: null }); // Use itemId!\n }\n }\n}\n\n// Attach redemption info to last item\nif (results.length > 0 && totalPointsUsed > 0) {\n results[results.length - 1].redemption = { type: 'points', amount: totalPointsUsed };\n}\nreturn results;"
},
"resources": {
"wallet": { "entity": "wallet", "userIdExpression": "{{ $('cart').userId }}" }
},
"stackingMode": "exclusive",
"priority": 100
})
Key points:
resources.wallet config is required for point redemptions to workredemption: { type, amount } on the last item to signal what to debititem.itemId (not item.id) - cart items have itemId field after normalization__order__)dryRun: false evaluations won't actually debit pointspromotions_evaluate_cart({
"cart": {
"userId": "user_123",
"items": [{ "id": "item1", "unitPrice": 2500, "quantity": 1 }],
"currencyCode": "USD",
"redemptions": [{ "type": "points", "amount": 20 }]
},
"dryRun": false,
"transactionId": "txn_order_123",
"orderId": "order_123"
})
Required fields for real redemptions:
cart.userId — must be present for wallet resolutiontransactionId — required when dryRun: false (identifies the transaction)orderId — optional but recommended for trackingdryRun: true to verify logicwallet.get_balancepromotions.list_usage({ promotionId }) to see redemptionsusages array in the response confirms successful debitCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub storelayer/skills --plugin storelayer