diff --git a/docs.json b/docs.json index ced1ba3..906318c 100644 --- a/docs.json +++ b/docs.json @@ -37,23 +37,18 @@ }, "navigation": { "tabs": [ - { - "tab": "CTRLRun", - "pages": [ - "index", - "execution-boundary" - ] - }, { "tab": "Documentation", "groups": [ { "group": "Start", "pages": [ + "index", "docs", "docs/why", "docs/not-only-agents", - "docs/agents-you-cant-modify" + "docs/agents-you-cant-modify", + "execution-boundary" ] }, { diff --git a/images/wordmark-dark.svg b/images/wordmark-dark.svg index fd0b506..d4f2a50 100644 --- a/images/wordmark-dark.svg +++ b/images/wordmark-dark.svg @@ -1,6 +1 @@ - - - - - CTRLRun - + diff --git a/images/wordmark-light.svg b/images/wordmark-light.svg index 5cd37c3..11dc86d 100644 --- a/images/wordmark-light.svg +++ b/images/wordmark-light.svg @@ -1,6 +1 @@ - - - - - CTRLRun - + diff --git a/index.mdx b/index.mdx index 0428a41..0e8175d 100644 --- a/index.mdx +++ b/index.mdx @@ -1,20 +1,17 @@ --- title: "Stop wrong, restricted, or malicious AI agent actions" -sidebarTitle: "CTRLRun" +sidebarTitle: "Overview" description: "Every AI agent action is checked against your rules before it runs. Allowed ones go through, sensitive ones wait for a person, forbidden ones are blocked." -mode: "custom" +mode: "wide" "og:title": "CTRLRun: stop wrong, restricted, or malicious agent actions" "twitter:title": "CTRLRun: stop wrong, restricted, or malicious agent actions" canonical: "https://ctrlrun.dev/" "og:url": "https://ctrlrun.dev/" --- -import { HomeSlides } from "/snippets/home-slides.jsx"; import { HowDiagram } from "/snippets/how-diagram.jsx"; -import { LaunchUpdates } from "/snippets/launch-updates.jsx";
-

THE AGENT IS PROBABILISTIC. THE ACTION IS NOT.

@@ -67,13 +64,14 @@ import { LaunchUpdates } from "/snippets/launch-updates.jsx";

Interactive walkthrough: follow one agent action through every check →

-
-
-

WHAT COMES NEXT

-

Something is coming that makes controlling agents easy for your business.

-

CTRLRun today is a library you install and wire in yourself. What we are building next takes the same boundary and makes it something a team runs without writing the wiring. Leave an address and we will tell you when it is ready. Nothing else, and no one else gets it.

- -
-
-
+ +## Built on this kernel + +Two products run on CTRLRun and credit it on every page. [ctrl ai agents](https://ctrlaiagents.com), the hosted product for a person or a team, puts this boundary under any agent you buy or build, with the inbox, the receipts and the analysis in one dashboard. [ctrl payments](https://ctrlpayments.com) is the same boundary for money: every payment an agent attempts is allowed, held for a person, or refused before it leaves, and there is a receipt either way. The kernel that decides and refuses is this one, Apache-2.0, and the receipt format they write is the one documented here. + +## Next + +- [Why](/docs/why): what CTRLRun believes and why. +- [Quickstart](/docs/get-started/quickstart): protect your first action. +- [The execution boundary](/execution-boundary): follow one agent action through every check. diff --git a/snippets/home-slides.jsx b/snippets/home-slides.jsx deleted file mode 100644 index 53336d5..0000000 --- a/snippets/home-slides.jsx +++ /dev/null @@ -1,138 +0,0 @@ -// Slides are a desktop presentation. Below this query the page is a plain document: on a -// phone the sections are taller than the screen, and `scroll-snap-stop: always` together with -// a slide height pinned to `innerHeight` (which moves every time the address bar collapses) -// made each flick re-snap mid-scroll. -export const HomeSlides = () => { - const SLIDE_MEDIA = '(min-width: 801px) and (hover: hover)'; - const SLIDES = [ - { id: 'overview', label: 'Overview' }, - { id: 'how-it-works', label: 'How it works' }, - { id: 'updates', label: 'What comes next' }, - ]; - const LAST = SLIDES.length - 1; - const [active, setActive] = useState(0); - const [ready, setReady] = useState(false); - const activeRef = useRef(0); - - // Where a scroll is *heading*, and when to stop believing it. `activeRef` tracks where the - // page is, and for the first half of an 800ms smooth scroll that is still the slide being - // left -- so a second arrow press mid-animation recomputed the same destination and - // re-issued the same trip, which reads as a keypress the page ignored. - const targetRef = useRef(-1); - const targetExpiry = useRef(0); - const currentIndex = () => - (targetRef.current >= 0 && performance.now() < targetExpiry.current ? targetRef.current : activeRef.current); - - const goTo = (index) => { - const slide = document.getElementById(SLIDES[index].id); - if (!slide) return; - const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - targetRef.current = index; - // Cleared on arrival below; this only bounds a trip that never lands. - targetExpiry.current = performance.now() + 1200; - slide.scrollIntoView({ behavior: reduced ? 'instant' : 'smooth', block: 'start' }); - }; - - // Scrolling is the browser's: `scroll-snap-type` in style.css does the snapping, for wheel, - // trackpad and touch alike. This adds keyboard navigation and the dots, and tracks which - // section is in view. Nothing here intercepts a scroll -- a wheel handler that stepped one - // slide per gesture fought every scroll it did not recognise. - useEffect(() => { - const root = document.documentElement; - const home = document.querySelector('.cr-home'); - if (!home) return; - const sections = SLIDES.map(slide => document.getElementById(slide.id)); - let frame = 0; - let headerHeight = 64; - const media = window.matchMedia(SLIDE_MEDIA); - - const updateActive = () => { - frame = 0; - let best = 0; - let visible = -1; - sections.forEach((section, index) => { - if (!section) return; - const rect = section.getBoundingClientRect(); - const amount = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, headerHeight)); - if (amount > visible) { visible = amount; best = index; } - }); - activeRef.current = best; - if (best === targetRef.current) { targetRef.current = -1; targetExpiry.current = 0; } - setActive(best); - }; - const onScroll = () => { - if (!frame) frame = requestAnimationFrame(updateActive); - }; - const onResize = () => { - headerHeight = Math.max(0, Math.round(home.getBoundingClientRect().top + window.scrollY)); - if (media.matches) { - root.style.setProperty('--cr-slide-top', `${headerHeight}px`); - root.style.setProperty('--cr-slide-height', `${Math.max(240, window.innerHeight - headerHeight)}px`); - } - onScroll(); - }; - const onKeyDown = (event) => { - if (!media.matches) return; - if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; - const target = event.target; - if (target instanceof Element && target.closest('input, textarea, select, video, a, [contenteditable], [role="dialog"], [role="slider"]')) return; - if (target instanceof Element && target.closest('button') && !target.closest('.cr-slide-dots')) return; - const current = currentIndex(); - let next; - if (['ArrowDown', 'ArrowRight', 'PageDown'].includes(event.key)) next = Math.min(LAST, current + 1); - else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(event.key)) next = Math.max(0, current - 1); - else if (event.key === 'Home') next = 0; - else if (event.key === 'End') next = LAST; - else return; - event.preventDefault(); - if (!event.repeat) goTo(next); - }; - - const applyMedia = () => { - if (media.matches) { - onResize(); - root.classList.add('cr-slides-active'); - } else { - root.classList.remove('cr-slides-active'); - root.style.removeProperty('--cr-slide-top'); - root.style.removeProperty('--cr-slide-height'); - } - setReady(media.matches); - }; - applyMedia(); - media.addEventListener('change', applyMedia); - window.addEventListener('scroll', onScroll, { passive: true }); - window.addEventListener('resize', onResize); - window.addEventListener('keydown', onKeyDown); - const header = document.getElementById('navbar'); - const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize); - if (header && observer) observer.observe(header); - const hashIndex = SLIDES.findIndex(slide => `#${slide.id}` === window.location.hash); - if (hashIndex >= 0) requestAnimationFrame(() => goTo(hashIndex)); - - return () => { - root.classList.remove('cr-slides-active'); - root.style.removeProperty('--cr-slide-top'); - root.style.removeProperty('--cr-slide-height'); - window.removeEventListener('scroll', onScroll); - window.removeEventListener('resize', onResize); - window.removeEventListener('keydown', onKeyDown); - media.removeEventListener('change', applyMedia); - observer?.disconnect(); - cancelAnimationFrame(frame); - }; - }, []); - - return ( - - ); -}; diff --git a/snippets/launch-updates.jsx b/snippets/launch-updates.jsx deleted file mode 100644 index 4476780..0000000 --- a/snippets/launch-updates.jsx +++ /dev/null @@ -1,68 +0,0 @@ -// The one form on the site: an address, and nothing else. -// -// ctrlrun.dev is a technical site for developers. It carries no pricing, no tiers and no sales -// path, and this is the single place it asks a reader for anything. That constraint is what -// makes the form's shape obvious: email, a honeypot, a button. No company field, no "what do -// your agents do", no qualification. Asking a stranger to describe their deployment before they -// have run `pip install` is how a project that wants users behaves like a project that wants -// leads. -// -// It posts to the same endpoint the site has always used, with `intent: 'launch-updates'`. The -// endpoint decides the subject line and the required fields from that intent and trusts nothing -// else the browser sends, so a new intent is a change in `website-form/api/interest.mjs` and not -// something a page can assert into existence. -// -// **The prose lives in `index.mdx`, not here.** Mintlify renders a snippet on the client, so -// anything drawn in this file is absent from the server-rendered HTML and invisible to a crawler -// or to any fetcher that does not run JavaScript. `commercial-tiers.jsx` learned that the -// expensive way: roughly 190 words of the page's plainest prose were missing from the HTML. Only -// what needs state is in here. -export const LaunchUpdates = () => { - const ENDPOINT = 'https://ctrlrun-review-form.vercel.app/api/interest'; - - const [sent, setSent] = useState(false); - const [email, setEmail] = useState(''); - const [website, setWebsite] = useState(''); - const [sending, setSending] = useState(false); - const [error, setError] = useState(''); - const requestId = useRef(null); - const sendingRef = useRef(false); - const track = name => window.dispatchEvent(new CustomEvent('ctrlrun:conversion', { detail: { name } })); - - const send = async event => { - event.preventDefault(); - if (sendingRef.current || sent) return; - // One id per filled-in form: an uncertain response can be retried without a second email. - if (!requestId.current) requestId.current = crypto.randomUUID(); - sendingRef.current = true; setSending(true); setError(''); - try { - const response = await fetch(ENDPOINT, { - method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ intent: 'launch-updates', email, website, requestId: requestId.current }), - signal: AbortSignal.timeout(15000) - }); - const data = await response.json(); - if (!response.ok || data.ok !== true || typeof data.id !== 'string') throw new Error(data.error || 'We could not confirm submission. Please retry or email us directly.'); - setSent(true); track('launch_updates_submitted'); - } catch (failure) { - setError(failure.name === 'TimeoutError' || failure.name === 'TypeError' ? 'We could not confirm submission. You can retry the same request safely, or email us directly.' : failure.message); - } finally { sendingRef.current = false; setSending(false); } - }; - - if (sent) return

You are on the list. We will write to that address once, when it is ready.

; - - return ( -
-
- - - -
- {error &&

{error}

} -

One address, one email when it ships. Sent through Resend and used for nothing else.

-
- ); -}; diff --git a/style.css b/style.css index 50d55a7..c0e5945 100644 --- a/style.css +++ b/style.css @@ -784,14 +784,6 @@ body:has(.cr-subpage) #search-bar-entry-mobile,body:has(.cr-subpage) #assistant- `.cr-tier-error` and `.cr-button` rather than restating them: the signup is a narrower version of a form that already existed here, and a second copy of the field styling would be a second thing to keep in step with the type scale. */ -.cr-home-updates { border-top:1px solid var(--cr-line); } -.cr-updates-inner { max-width:640px; } -.cr-updates-body { margin:18px 0 28px; color:var(--cr-muted); } -.cr-updates-form fieldset { display:flex; flex-wrap:wrap; gap:12px; border:0; padding:0; margin:0; align-items:flex-end; } -.cr-updates-field { flex:1 1 260px; } -.cr-updates-field input { width:100%; } -.cr-updates-done { border-top:2px solid var(--cr-accent); padding:20px 0 0; font-weight:550; } -.cr-updates-form .cr-caption { margin-top:12px; } /* The label is present for a screen reader and absent for everyone else: one field with a placeholder needs no visible label, and a form with no label at all is not navigable. */ .cr-visually-hidden { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }