Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 7 additions & 4 deletions examples/sales/salesforce-pipeline.connector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// biome-ignore-all format: stays compact for the landing-page code panel
import { ConnectorRuntime, type SyncContext } from "@lobu/connector-sdk";

export default class SalesforcePipelineConnector extends ConnectorRuntime {
interface Checkpoint { last_modified: string }
interface Opportunity { Id: string; Name: string; StageName: string; LastModifiedDate: string }

export default class SalesforcePipelineConnector extends ConnectorRuntime<Checkpoint> {
readonly definition = {
key: "salesforce-pipeline",
name: "Salesforce pipeline",
Expand All @@ -10,11 +13,11 @@ export default class SalesforcePipelineConnector extends ConnectorRuntime {
feeds: { opportunities: { key: "opportunities", name: "Opportunities" } },
};

async sync(ctx: SyncContext) {
const since = (ctx.checkpoint as any)?.last_modified ?? "2000-01-01T00:00:00Z";
async sync(ctx: SyncContext<Checkpoint>) {
const since = ctx.checkpoint?.last_modified ?? "2000-01-01T00:00:00Z";
const q = `SELECT Id,Name,StageName,LastModifiedDate FROM Opportunity WHERE LastModifiedDate > ${since} LIMIT 200`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add deterministic ordering before deriving the next checkpoint.

Checkpointing from records.at(-1) is unsafe without ORDER BY LastModifiedDate ASC; API return order is not guaranteed, which can skip or replay events across sync runs.

Suggested fix
-    const q = `SELECT Id,Name,StageName,LastModifiedDate FROM Opportunity WHERE LastModifiedDate > ${since} LIMIT 200`;
+    const q = `SELECT Id,Name,StageName,LastModifiedDate FROM Opportunity WHERE LastModifiedDate > ${since} ORDER BY LastModifiedDate ASC LIMIT 200`;

Also applies to: 29-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/sales/salesforce-pipeline.connector.ts` at line 18, The query string
q used to fetch Opportunities lacks deterministic ordering before deriving the
next checkpoint from records.at(-1), so add an explicit ORDER BY
LastModifiedDate ASC (and include a unique tiebreaker like Id ASC) to the SOQL
in the places building q (the SELECT ... FROM Opportunity WHERE ... LIMIT 200
occurrences) so that records.at(-1) consistently represents the oldest streamed
row; update both query constructions (the q variable instances) to include
"ORDER BY LastModifiedDate ASC, Id ASC" before LIMIT.

const r = await fetch(`${ctx.config.instance_url}/services/data/v60.0/query?q=${encodeURIComponent(q)}`);
const records: any[] = (await r.json() as any).records ?? [];
const records = ((await r.json()) as { records?: Opportunity[] }).records ?? [];
Comment on lines 19 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-2xx responses before JSON parsing.

await r.json() on failed/non-JSON responses can throw and hide the actual failure mode. Add an r.ok guard with clear error context.

Suggested fix
     const r = await fetch(`${ctx.config.instance_url}/services/data/v60.0/query?q=${encodeURIComponent(q)}`);
+    if (!r.ok) {
+      throw new Error(`Salesforce query failed: ${r.status} ${r.statusText}`);
+    }
     const records = ((await r.json()) as { records?: Opportunity[] }).records ?? [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/sales/salesforce-pipeline.connector.ts` around lines 19 - 20, Before
calling await r.json(), check the HTTP response status by using r.ok on the
response returned from the fetch call (variable r) and throw or return a clear
error that includes response.status and response.statusText (and optionally
await r.text() for body) so failures are not masked by json parsing; update the
fetch-handling around the const r = await fetch(...) and the subsequent records
extraction to guard on r.ok and provide contextual error information before
attempting to parse JSON.

return {
events: records.map((o) => ({
origin_id: o.Id,
Expand Down
40 changes: 40 additions & 0 deletions examples/sales/skills/account-brief/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: account-brief
description: Build a pre-renewal brief for a tracked account from its recent public news and announcements. Use before a renewal call or QBR, after the account-health watcher flags a risk. Reading public news is allowed; do not log in, submit forms, or touch any CRM write endpoint.
nixPackages:
- jq
network:
allow:
- .reuters.com
- .apnews.com
judge:
- domain: newsapi.org
judge: news-read
- domain: .newsapi.org
judge: news-read
judges:
news-read: >
Allow GET reads of public news and headlines. Deny logins, posting,
and any account, billing, or write action. Fail closed if unclear.
---

# Account brief

Use this skill when the user asks for a pre-renewal brief on a tracked account,
or when the `account-health-monitor` watcher flags a risk signal.

## Steps

1. Resolve the company name from the `organization` entity.
2. Fetch recent headlines and filter to the last 90 days.
3. Summarize anything that moves renewal risk: leadership changes, funding,
layoffs, M&A, or competitive losses.
4. Save a `renewal-risk` entity per material signal, linked to the account with
the `affects` relationship.

## Rules

- Read public sources only. Never log in, submit forms, or change account,
billing, or profile data.
- If a source is paywalled or asks for credentials, skip it and note the gap.
- Keep the brief to five bullets or fewer; the rep reads it on a phone.
52 changes: 28 additions & 24 deletions packages/landing/scripts/gen-landing-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,24 @@
* The landing page shows SOURCE CODE, so we slice the raw `.ts` text into
* budget-sized sections; we never import/execute the config.
*
* Each primitive section shows ONE canonical pinned example, used as the
* generic fallback when no use case is selected:
* Every primitive section is pinned to the single `sales` example so the
* homepage tells one coherent story (shown config-first on the page):
*
* connector -> examples/ecommerce/stripe-charges.connector.ts
* connector -> examples/sales/salesforce-pipeline.connector.ts
* memorySchema -> examples/sales/lobu.config.ts (defineEntityType slice)
* watcher -> examples/sales/lobu.config.ts (defineWatcher slice)
* reaction -> examples/finance/reconciliation-monitor.reaction.ts
* reaction -> examples/sales/account-health-monitor.reaction.ts
* agentConfig -> examples/sales/lobu.config.ts (imports + defineAgent slice)
* skill -> examples/office-bot/.../SKILL.md
* skill -> examples/sales/skills/account-brief/SKILL.md
*
* Plus a list of every `examples/*\/lobu.config.ts` for BrowseExamplesSection:
*
* examples -> [{ slug, label, description, githubUrl }]
*
* And, under `useCases`, per-use-case connector / memorySchema / watcher
* snippets keyed by the example dir slug. The interactive use-case tab strip
* on the landing page swaps these three sections; everything else stays
* generic. Hero copy is not part of this manifest.
* snippets keyed by the example dir slug. The /for/<useCase> SEO pages use
* these route-specific snippets; the homepage stays pinned to sales. Hero
* copy is not part of this manifest.
*
* Output: packages/landing/src/generated/landing-snippets.json
*/
Expand All @@ -41,21 +41,26 @@ const outFile = resolve(__dirname, "../src/generated/landing-snippets.json");

const CONFIG_FILE = "lobu.config.ts";

// Every pinned snippet comes from the one `sales` example so the homepage reads
// as a single coherent app (Salesforce connector -> account/renewal entities ->
// health watcher -> reaction -> agent + skill), shown config-first. The
// per-use-case route data (useCases below) still spans many examples for the
// /for/<useCase> SEO pages.
const PINNED = {
connector: {
slug: "ecommerce",
path: "stripe-charges.connector.ts",
slug: "sales",
path: "salesforce-pipeline.connector.ts",
},
agentConfig: { slug: "sales" },
memorySchema: { slug: "sales" },
watcher: { slug: "sales" },
reaction: {
slug: "finance",
path: "reconciliation-monitor.reaction.ts",
slug: "sales",
path: "account-health-monitor.reaction.ts",
},
skill: {
slug: "office-bot",
path: "skills/deliveroo-order/SKILL.md",
slug: "sales",
path: "skills/account-brief/SKILL.md",
},
} as const;

Expand Down Expand Up @@ -336,21 +341,16 @@ function trimSkillMarkdown(raw: string): string {
continue;
}

// A judge policy (`<name>: >` block scalar under judges), keep a short
// folded block scalar (valid YAML that reads naturally) in place of the
// full multi-line policy text.
// A judge policy (`<name>: >` block scalar under judges): keep it as a
// folded block scalar but cap it at the first 2 non-empty lines of the
// real policy text so the snippet stays compact. Authors keep landing
// policies <=2 lines so nothing is cut mid-sentence.
const blockScalar = /^(\s*)([\w-]+):\s*[>|][+-]?\s*$/.exec(line);
if (blockScalar) {
const baseIndent = blockScalar[1].length;
const policyName = blockScalar[2];
const childPad = " ".repeat(baseIndent + 2);
out.push(`${" ".repeat(baseIndent)}${policyName}: >`);
out.push(
`${childPad}Allow reads and basket changes. Deny checkout, payment,`
);
out.push(
`${childPad}saved cards, address, or profile changes. Fail closed if unclear.`
);
let kept = 0;
let j = i + 1;
while (j < fm.length) {
const child = fm[j];
Expand All @@ -360,6 +360,10 @@ function trimSkillMarkdown(raw: string): string {
}
const childIndent = child.length - child.trimStart().length;
if (childIndent <= baseIndent) break;
if (kept < 2) {
out.push(child);
kept++;
}
j++;
}
i = j;
Expand Down
2 changes: 1 addition & 1 deletion packages/landing/src/components/ArchitectureDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function Header() {
class="font-display text-[1.85rem] font-bold leading-[1.1] tracking-tight sm:text-[2.25rem]"
style={{ color: "var(--color-page-text)" }}
>
Events in. Entities out. Agents on top.
Raw events in. Typed memory out. Agents act.
</h2>
<p
class="mx-auto mt-3 max-w-2xl text-[15px] leading-relaxed"
Expand Down
11 changes: 11 additions & 0 deletions packages/landing/src/components/CTA.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getLobuBaseUrl } from "../use-case-showcases";
import { ScheduleCallButton, ScheduleCallIcon } from "./ScheduleDialog";

function HexCluster() {
return (
Expand Down Expand Up @@ -76,6 +77,16 @@ export function CTA({ startUrl = getLobuBaseUrl() }: { startUrl?: string }) {
>
Read the docs
</a>
<ScheduleCallButton
class="inline-flex items-center gap-2 text-[14px] font-medium px-5 h-10 rounded-lg transition-colors hover:bg-[color:var(--color-page-surface-dim)]"
style={{
color: "var(--color-page-text)",
border: "1px solid var(--color-page-border)",
}}
>
<ScheduleCallIcon />
Schedule a call
</ScheduleCallButton>
</div>
</div>
<div class="hidden md:flex items-center justify-center p-10">
Expand Down
Loading
Loading