@agent-native/core 0.205.0 → 0.206.0-nightly-20261009011246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/a2a/client.d.ts +2 -2
- package/dist/agent/engine/anthropic-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.js +23 -8
- package/dist/agent/engine/error-telemetry.d.ts +5 -0
- package/dist/agent/engine/error-telemetry.js +41 -0
- package/dist/agent/engine/registry.d.ts +1 -6
- package/dist/agent/engine/registry.js +2 -31
- package/dist/agent/model-config.d.ts +26 -14
- package/dist/agent/model-config.js +131 -8
- package/dist/agent/model-version.d.ts +19 -0
- package/dist/agent/model-version.js +153 -0
- package/dist/agent/production-agent.d.ts +1 -0
- package/dist/agent/production-agent.js +24 -6
- package/dist/agent/provider-model-selection.js +6 -4
- package/dist/agent/run-manager.js +5 -11
- package/dist/agent/thread-data-builder.js +14 -6
- package/dist/authorization/check-action.js +8 -4
- package/dist/chat-threads/store.js +22 -9
- package/dist/client/analytics.d.ts +1 -0
- package/dist/client/analytics.js +10 -2
- package/dist/client/chat/runtime.js +18 -0
- package/dist/client/chat-model-groups.d.ts +3 -0
- package/dist/client/error-format.js +10 -7
- package/dist/client/onboarding/use-onboarding.d.ts +12 -0
- package/dist/client/onboarding/use-onboarding.js +491 -4
- package/dist/client/session-replay-privacy.d.ts +8 -0
- package/dist/client/session-replay-privacy.js +8 -0
- package/dist/client/session-replay.d.ts +1 -0
- package/dist/client/session-replay.js +18 -9
- package/dist/client/sse-event-processor.js +10 -6
- package/dist/client/use-chat-models.d.ts +5 -0
- package/dist/client/use-chat-models.js +51 -5
- package/dist/client/use-session.d.ts +8 -1
- package/dist/client/use-session.js +20 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.js +353 -3
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/jobs/backfill-run-history-ownership.d.ts +12 -0
- package/dist/jobs/backfill-run-history-ownership.js +87 -0
- package/dist/jobs/background-automation-runner.js +3 -9
- package/dist/jobs/remote-execution.js +3 -9
- package/dist/jobs/run-history-ownership.d.ts +5 -0
- package/dist/jobs/run-history-ownership.js +16 -0
- package/dist/jobs/run-now.js +2 -3
- package/dist/localization/agent-resources-messages.d.ts +3 -0
- package/dist/localization/agent-resources-messages.js +33 -0
- package/dist/mcp/actions/create-org-service-token.js +5 -7
- package/dist/mcp/build-server.d.ts +23 -18
- package/dist/mcp/build-server.js +220 -212
- package/dist/mcp/connect-route.d.ts +11 -2
- package/dist/mcp/connect-route.js +34 -24
- package/dist/mcp/connect-store.d.ts +9 -13
- package/dist/mcp/connect-store.js +11 -24
- package/dist/mcp/credential-membership.js +17 -6
- package/dist/mcp/oauth-route.d.ts +11 -1
- package/dist/mcp/oauth-route.js +28 -6
- package/dist/mcp/oauth-token.d.ts +12 -2
- package/dist/mcp/oauth-token.js +15 -4
- package/dist/mcp/server.js +9 -5
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/posthog-ai.d.ts +1 -0
- package/dist/observability/posthog-ai.js +20 -8
- package/dist/observability/routes.d.ts +6 -6
- package/dist/observability/traces.js +61 -39
- package/dist/org/app-roles.d.ts +8 -1
- package/dist/org/app-roles.js +29 -9
- package/dist/org/context.js +24 -8
- package/dist/org/index.d.ts +2 -2
- package/dist/org/workspace-app-identity.d.ts +1 -0
- package/dist/org/workspace-app-identity.js +25 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +3 -3
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/scripts/agent-engines/list-agent-engines.js +1 -0
- package/dist/server/action-routes.js +8 -1
- package/dist/server/agent-chat-plugin.js +1 -0
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/auth.js +33 -17
- package/dist/server/bearer-credential-refusal.d.ts +23 -0
- package/dist/server/bearer-credential-refusal.js +67 -0
- package/dist/server/better-auth-instance.js +13 -6
- package/dist/server/builder-browser.d.ts +2 -0
- package/dist/server/builder-browser.js +16 -3
- package/dist/server/capture-error.d.ts +1 -0
- package/dist/server/capture-error.js +28 -4
- package/dist/server/complete-text.js +16 -3
- package/dist/server/core-routes-plugin.js +2 -0
- package/dist/server/dev-action-bridge.js +7 -1
- package/dist/server/google-oauth.js +6 -3
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/recap-image-route.js +4 -0
- package/dist/server/request-context.d.ts +16 -0
- package/dist/server/request-context.js +17 -0
- package/dist/shared/agent-chat-context.d.ts +1 -0
- package/dist/shared/agent-chat-context.js +26 -0
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/reasoning-effort.js +10 -1
- package/dist/shared/ssr-session-bootstrap.d.ts +7 -0
- package/dist/shared/ssr-session-bootstrap.js +21 -0
- package/dist/sharing/access.js +86 -5
- package/dist/templates/builder-code-starter/server/routes/index.get.ts +2 -0
- package/dist/triggers/routes.js +2 -1
- package/docs/content/automation-connectors.mdx +5 -3
- package/docs/content/automations.mdx +78 -49
- package/docs/content/observability.mdx +16 -5
- package/docs/content/organizations-teams-permissions.mdx +44 -2
- package/docs/content/recurring-jobs.mdx +4 -8
- package/docs/content/template-forms.mdx +6 -0
- package/package.json +5 -4
- package/src/templates/builder-code-starter/server/routes/index.get.ts +2 -0
|
@@ -58,6 +58,11 @@ export interface RequestContext {
|
|
|
58
58
|
userEmail?: string;
|
|
59
59
|
/** Keep data-source credentials within the selected org, not the user. */
|
|
60
60
|
credentialScope?: "org";
|
|
61
|
+
/** Provenance set only after a stored org service token is verified. */
|
|
62
|
+
verifiedServiceIdentity?: {
|
|
63
|
+
userEmail: string;
|
|
64
|
+
orgId: string;
|
|
65
|
+
};
|
|
61
66
|
identityAuthenticatedAtMs?: number;
|
|
62
67
|
identitySessionToken?: string;
|
|
63
68
|
authUserId?: string;
|
|
@@ -129,6 +134,17 @@ export interface RequestContext {
|
|
|
129
134
|
};
|
|
130
135
|
run?: RequestRunContext;
|
|
131
136
|
}
|
|
137
|
+
type RequestContextEvent = {
|
|
138
|
+
context?: Record<string, unknown>;
|
|
139
|
+
};
|
|
140
|
+
export declare function markVerifiedServiceIdentityForEvent(event: RequestContextEvent, identity: {
|
|
141
|
+
userEmail: string;
|
|
142
|
+
orgId: string;
|
|
143
|
+
}): void;
|
|
144
|
+
export declare function getVerifiedServiceIdentityFromEvent(event: RequestContextEvent): {
|
|
145
|
+
userEmail: string;
|
|
146
|
+
orgId: string;
|
|
147
|
+
} | undefined;
|
|
132
148
|
export declare function markExplicitPersonalOrgScope(event: {
|
|
133
149
|
context?: Record<string, unknown>;
|
|
134
150
|
}): void;
|
|
@@ -38,6 +38,23 @@ function processEnv(name) {
|
|
|
38
38
|
return process.env?.[name];
|
|
39
39
|
}
|
|
40
40
|
const EXPLICIT_PERSONAL_ORG_SCOPE_KEY = "__anExplicitPersonalOrgScope";
|
|
41
|
+
const VERIFIED_SERVICE_IDENTITY_EVENT_KEY = "__anVerifiedServiceIdentity";
|
|
42
|
+
export function markVerifiedServiceIdentityForEvent(event, identity) {
|
|
43
|
+
const context = (event.context ??= {});
|
|
44
|
+
context[VERIFIED_SERVICE_IDENTITY_EVENT_KEY] = identity;
|
|
45
|
+
}
|
|
46
|
+
export function getVerifiedServiceIdentityFromEvent(event) {
|
|
47
|
+
const identity = event.context?.[VERIFIED_SERVICE_IDENTITY_EVENT_KEY];
|
|
48
|
+
const userEmail = identity?.userEmail;
|
|
49
|
+
const orgId = identity?.orgId;
|
|
50
|
+
if (typeof userEmail !== "string" ||
|
|
51
|
+
!userEmail.trim() ||
|
|
52
|
+
typeof orgId !== "string" ||
|
|
53
|
+
!orgId.trim()) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { userEmail: userEmail.trim(), orgId: orgId.trim() };
|
|
57
|
+
}
|
|
41
58
|
export function markExplicitPersonalOrgScope(event) {
|
|
42
59
|
if (event.context) {
|
|
43
60
|
event.context[EXPLICIT_PERSONAL_ORG_SCOPE_KEY] = true;
|
|
@@ -2,3 +2,4 @@ import { splitAgentKitMessageContext } from "@agent-native/agentkit/chat-context
|
|
|
2
2
|
export { appendAgentChatContextToMessage } from "@agent-native/agentkit/chat-context";
|
|
3
3
|
export type { AgentKitMessageParts as AgentChatMessageParts } from "@agent-native/agentkit/chat-context";
|
|
4
4
|
export declare const splitAgentChatContextFromMessage: typeof splitAgentKitMessageContext;
|
|
5
|
+
export declare function stripAgentChatContextFromMessage(text: string): string;
|
|
@@ -1,3 +1,29 @@
|
|
|
1
1
|
import { splitAgentKitMessageContext } from "@agent-native/agentkit/chat-context";
|
|
2
2
|
export { appendAgentChatContextToMessage } from "@agent-native/agentkit/chat-context";
|
|
3
3
|
export const splitAgentChatContextFromMessage = splitAgentKitMessageContext;
|
|
4
|
+
const ENCODED_AGENT_CHAT_CONTEXT_OPENING = '<context data-agentkit-context-encoding="entities-v1">';
|
|
5
|
+
const LEGACY_CONTEXT_OPEN_PATTERN = /<context(?=[\s>])[^>]*>/gi;
|
|
6
|
+
export function stripAgentChatContextFromMessage(text) {
|
|
7
|
+
LEGACY_CONTEXT_OPEN_PATTERN.lastIndex = 0;
|
|
8
|
+
const opening = LEGACY_CONTEXT_OPEN_PATTERN.exec(text);
|
|
9
|
+
if (!opening)
|
|
10
|
+
return text;
|
|
11
|
+
if (opening[0] === ENCODED_AGENT_CHAT_CONTEXT_OPENING)
|
|
12
|
+
return splitAgentChatContextFromMessage(text).message;
|
|
13
|
+
const closingPattern = /<\/context(?=[\s>])[^>]*>/gi;
|
|
14
|
+
closingPattern.lastIndex = opening.index + opening[0].length;
|
|
15
|
+
let lastClosing = null;
|
|
16
|
+
let closing = closingPattern.exec(text);
|
|
17
|
+
while (closing) {
|
|
18
|
+
lastClosing = closing;
|
|
19
|
+
closing = closingPattern.exec(text);
|
|
20
|
+
}
|
|
21
|
+
if (!lastClosing)
|
|
22
|
+
return text.slice(0, opening.index);
|
|
23
|
+
LEGACY_CONTEXT_OPEN_PATTERN.lastIndex =
|
|
24
|
+
lastClosing.index + lastClosing[0].length;
|
|
25
|
+
if (LEGACY_CONTEXT_OPEN_PATTERN.exec(text))
|
|
26
|
+
return text.slice(0, opening.index);
|
|
27
|
+
return (text.slice(0, opening.index) +
|
|
28
|
+
text.slice(lastClosing.index + lastClosing[0].length));
|
|
29
|
+
}
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { agentChat, type AgentChatMessage, type AgentChatCallOptions, type AgentChatResponse, } from "./agent-chat.js";
|
|
2
|
-
export { appendAgentChatContextToMessage, splitAgentChatContextFromMessage, type AgentChatMessageParts, } from "./agent-chat-context.js";
|
|
2
|
+
export { appendAgentChatContextToMessage, splitAgentChatContextFromMessage, stripAgentChatContextFromMessage, type AgentChatMessageParts, } from "./agent-chat-context.js";
|
|
3
3
|
export { agentEnv, type EnvVar } from "./agent-env.js";
|
|
4
4
|
export { APP_STATUS, DEFAULT_APP_STATUS, getAppStatus, type AppStatus, } from "./app-status.js";
|
|
5
5
|
export { extractOAuthStateAppId, extractOAuthStateProvider, } from "./oauth-state.js";
|
package/dist/shared/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { agentChat, } from "./agent-chat.js";
|
|
2
|
-
export { appendAgentChatContextToMessage, splitAgentChatContextFromMessage, } from "./agent-chat-context.js";
|
|
2
|
+
export { appendAgentChatContextToMessage, splitAgentChatContextFromMessage, stripAgentChatContextFromMessage, } from "./agent-chat-context.js";
|
|
3
3
|
export { agentEnv } from "./agent-env.js";
|
|
4
4
|
export { APP_STATUS, DEFAULT_APP_STATUS, getAppStatus, } from "./app-status.js";
|
|
5
5
|
export { extractOAuthStateAppId, extractOAuthStateProvider, } from "./oauth-state.js";
|
|
@@ -116,8 +116,11 @@ function isClaudeReasoningModel(model) {
|
|
|
116
116
|
return true;
|
|
117
117
|
if (id.includes("sonnet-5") || id.includes("sonnet-4-6"))
|
|
118
118
|
return true;
|
|
119
|
-
if (id.includes("haiku-
|
|
119
|
+
if (id.includes("haiku-5-5") ||
|
|
120
|
+
id.includes("haiku-5.5") ||
|
|
121
|
+
id.includes("haiku-4-5")) {
|
|
120
122
|
return true;
|
|
123
|
+
}
|
|
121
124
|
return claudeOpusAtLeast(id, 4, 6);
|
|
122
125
|
}
|
|
123
126
|
export function supportsClaudeAdaptiveThinking(model) {
|
|
@@ -126,6 +129,8 @@ export function supportsClaudeAdaptiveThinking(model) {
|
|
|
126
129
|
const id = model.toLowerCase().replace(/^anthropic\//, "");
|
|
127
130
|
if (id.includes("fable-5") || id.includes("mythos-5"))
|
|
128
131
|
return true;
|
|
132
|
+
if (id.includes("haiku-5-5") || id.includes("haiku-5.5"))
|
|
133
|
+
return true;
|
|
129
134
|
if (id.includes("sonnet-5") || id.includes("sonnet-4-6"))
|
|
130
135
|
return true;
|
|
131
136
|
return claudeOpusAtLeast(id, 4, 6);
|
|
@@ -150,6 +155,8 @@ function supportsClaudeXHigh(model) {
|
|
|
150
155
|
const id = model.toLowerCase().replace(/^anthropic\//, "");
|
|
151
156
|
if (id.includes("fable-5"))
|
|
152
157
|
return true;
|
|
158
|
+
if (id.includes("haiku-5-5") || id.includes("haiku-5.5"))
|
|
159
|
+
return true;
|
|
153
160
|
if (id.includes("sonnet-5"))
|
|
154
161
|
return true;
|
|
155
162
|
return claudeOpusAtLeast(id, 4, 7);
|
|
@@ -161,6 +168,8 @@ function claudeAcceptsSamplingParams(model) {
|
|
|
161
168
|
const id = model.toLowerCase().replace(/^anthropic\//, "");
|
|
162
169
|
if (id.includes("fable-5") || id.includes("mythos-5"))
|
|
163
170
|
return false;
|
|
171
|
+
if (id.includes("haiku-5-5") || id.includes("haiku-5.5"))
|
|
172
|
+
return false;
|
|
164
173
|
if (id.includes("sonnet-5"))
|
|
165
174
|
return false;
|
|
166
175
|
return !claudeOpusAtLeast(id, 4, 7);
|
|
@@ -27,4 +27,11 @@ export declare function isSessionNavigationPending(): boolean;
|
|
|
27
27
|
export declare function navigateForSession(href: string): boolean;
|
|
28
28
|
/** Calls `listener` whenever a claimed navigation is released. */
|
|
29
29
|
export declare function subscribeSessionNavigation(listener: () => void): () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Whether this browser carries the readable cookie the server keeps beside a
|
|
32
|
+
* signed-in session. It says a session is likely, never that one exists: the
|
|
33
|
+
* session read decides. Same rule as the bootstrap script below, so a read
|
|
34
|
+
* started on it starts exactly when the early session read does.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hasSessionHint(sessionHintCookieName?: string): boolean;
|
|
30
37
|
export declare function getSsrSessionBootstrapScriptBody(sessionPath: string, sessionHintCookieName?: string): string;
|
|
@@ -50,6 +50,27 @@ export function subscribeSessionNavigation(listener) {
|
|
|
50
50
|
window.addEventListener(SESSION_NAVIGATION_RELEASED_EVENT, listener);
|
|
51
51
|
return () => window.removeEventListener(SESSION_NAVIGATION_RELEASED_EVENT, listener);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether this browser carries the readable cookie the server keeps beside a
|
|
55
|
+
* signed-in session. It says a session is likely, never that one exists: the
|
|
56
|
+
* session read decides. Same rule as the bootstrap script below, so a read
|
|
57
|
+
* started on it starts exactly when the early session read does.
|
|
58
|
+
*/
|
|
59
|
+
export function hasSessionHint(sessionHintCookieName) {
|
|
60
|
+
if (typeof document === "undefined")
|
|
61
|
+
return false;
|
|
62
|
+
return document.cookie.split(";").some((cookie) => {
|
|
63
|
+
const entry = cookie.trim();
|
|
64
|
+
const separator = entry.indexOf("=");
|
|
65
|
+
if (separator < 1 || entry.slice(separator + 1) !== "1")
|
|
66
|
+
return false;
|
|
67
|
+
const name = entry.slice(0, separator);
|
|
68
|
+
return sessionHintCookieName
|
|
69
|
+
? name === sessionHintCookieName
|
|
70
|
+
: name === "an_session_hint" ||
|
|
71
|
+
(name.startsWith("an_session_") && name.endsWith("_hint"));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
53
74
|
export function getSsrSessionBootstrapScriptBody(sessionPath, sessionHintCookieName) {
|
|
54
75
|
return `(function __anEarlySessionBootstrap() {
|
|
55
76
|
if (window.__agentNativeSessionBootstrap) return;
|
package/dist/sharing/access.js
CHANGED
|
@@ -4,8 +4,8 @@ import { getScopedDbExec, withDbExec } from "../db/client.js";
|
|
|
4
4
|
import { evaluateFeatureFlagStrict } from "../feature-flags/store.js";
|
|
5
5
|
import { CROSS_APP_ORG_FEDERATION_FLAG } from "../org/feature-flags.js";
|
|
6
6
|
import { isMissingOrganizationTableError } from "../org/membership.js";
|
|
7
|
-
import { orgMembers } from "../org/schema.js";
|
|
8
|
-
import {
|
|
7
|
+
import { orgMembers, organizations } from "../org/schema.js";
|
|
8
|
+
import { implicitServiceOrgRole } from "../org/service-identity.js";
|
|
9
9
|
import { getRequestAuthCapability, getRequestContext, getRequestUserEmail, getRequestOrgId, } from "../server/request-context.js";
|
|
10
10
|
import { workspaceUserGroupsIncludeUser, workspaceUserGroupsTable, } from "../workspace-connections/groups.js";
|
|
11
11
|
import { listShareableResources, requireShareableResource, } from "./registry.js";
|
|
@@ -63,8 +63,86 @@ function normalizeEmailForAccess(email) {
|
|
|
63
63
|
function emailColumnMatches(column, email) {
|
|
64
64
|
return sql `lower(${column}) = ${email}`;
|
|
65
65
|
}
|
|
66
|
+
function orgMembershipScope(ctx, orgId) {
|
|
67
|
+
const requestContext = getRequestContext();
|
|
68
|
+
const serviceIdentity = requestContext?.verifiedServiceIdentity;
|
|
69
|
+
const normalizedUserEmail = normalizeEmailForAccess(ctx.userEmail);
|
|
70
|
+
if (serviceIdentity) {
|
|
71
|
+
if (normalizeEmailForAccess(serviceIdentity.userEmail) !==
|
|
72
|
+
normalizedUserEmail ||
|
|
73
|
+
serviceIdentity.orgId !== orgId ||
|
|
74
|
+
requestContext.orgId !== orgId ||
|
|
75
|
+
ctx.orgId !== orgId ||
|
|
76
|
+
!implicitServiceOrgRole({
|
|
77
|
+
email: serviceIdentity.userEmail,
|
|
78
|
+
orgId,
|
|
79
|
+
requestOrgId: requestContext.orgId,
|
|
80
|
+
})) {
|
|
81
|
+
return sql `1=0`;
|
|
82
|
+
}
|
|
83
|
+
return sql `exists (
|
|
84
|
+
select 1 from organizations as service_org
|
|
85
|
+
where service_org.id = ${orgId}
|
|
86
|
+
and coalesce(trim(service_org.identity_authority), '') = ''
|
|
87
|
+
and coalesce(trim(service_org.identity_id), '') = ''
|
|
88
|
+
)`;
|
|
89
|
+
}
|
|
90
|
+
if (implicitServiceOrgRole({
|
|
91
|
+
email: ctx.userEmail,
|
|
92
|
+
orgId,
|
|
93
|
+
requestOrgId: ctx.orgId,
|
|
94
|
+
})) {
|
|
95
|
+
const federationGuard = ctx.federationMembershipValidated === true
|
|
96
|
+
? sql `1=1`
|
|
97
|
+
: sql `not exists (
|
|
98
|
+
select 1 from organizations as federation_org
|
|
99
|
+
where federation_org.id = ${orgId}
|
|
100
|
+
and (
|
|
101
|
+
coalesce(trim(federation_org.identity_authority), '') <> ''
|
|
102
|
+
or coalesce(trim(federation_org.identity_id), '') <> ''
|
|
103
|
+
)
|
|
104
|
+
)`;
|
|
105
|
+
return sql `exists (
|
|
106
|
+
select 1 from org_members as service_alias_member
|
|
107
|
+
where service_alias_member.org_id = ${orgId}
|
|
108
|
+
and lower(service_alias_member.email) = ${normalizedUserEmail}
|
|
109
|
+
and service_alias_member.federation_removal_pending_at is null
|
|
110
|
+
) and ${federationGuard}`;
|
|
111
|
+
}
|
|
112
|
+
return sql `1=1`;
|
|
113
|
+
}
|
|
66
114
|
async function isOrgMember(reg, memberOrgId, email, ctx) {
|
|
115
|
+
const requestContext = getRequestContext();
|
|
116
|
+
const verifiedServiceIdentity = requestContext?.verifiedServiceIdentity;
|
|
67
117
|
const db = reg.getDb();
|
|
118
|
+
if (normalizeEmailForAccess(requestContext?.userEmail) === email &&
|
|
119
|
+
normalizeEmailForAccess(verifiedServiceIdentity?.userEmail) === email &&
|
|
120
|
+
implicitServiceOrgRole({
|
|
121
|
+
email: verifiedServiceIdentity?.userEmail,
|
|
122
|
+
orgId: memberOrgId,
|
|
123
|
+
requestOrgId: requestContext?.orgId,
|
|
124
|
+
}) &&
|
|
125
|
+
verifiedServiceIdentity?.orgId === memberOrgId) {
|
|
126
|
+
try {
|
|
127
|
+
const [organization] = await db
|
|
128
|
+
.select({
|
|
129
|
+
identityAuthority: organizations.identityAuthority,
|
|
130
|
+
identityId: organizations.identityId,
|
|
131
|
+
})
|
|
132
|
+
.from(organizations)
|
|
133
|
+
.where(eq(organizations.id, memberOrgId))
|
|
134
|
+
.limit(1);
|
|
135
|
+
if (organization &&
|
|
136
|
+
!String(organization.identityAuthority ?? "").trim() &&
|
|
137
|
+
!String(organization.identityId ?? "").trim()) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!isMissingOrganizationTableError(error))
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
68
146
|
let rows;
|
|
69
147
|
try {
|
|
70
148
|
rows = await db
|
|
@@ -123,6 +201,7 @@ export function accessFilter(resourceTable, sharesTable, rawCtx = currentAccess(
|
|
|
123
201
|
const publicAllowed = reg?.allowPublic !== false;
|
|
124
202
|
const includePublic = (options.includePublic ?? false) && publicAllowed;
|
|
125
203
|
const clauses = [];
|
|
204
|
+
const orgMembership = orgId ? orgMembershipScope(ctx, orgId) : sql `1=1`;
|
|
126
205
|
if (normalizedUserEmail) {
|
|
127
206
|
clauses.push(and(emailColumnMatches(resourceTable.ownerEmail, normalizedUserEmail), ownerScopeFilter(reg, resourceTable, ctx)));
|
|
128
207
|
}
|
|
@@ -131,7 +210,7 @@ export function accessFilter(resourceTable, sharesTable, rawCtx = currentAccess(
|
|
|
131
210
|
clauses.push(eq(resourceTable.visibility, "public"));
|
|
132
211
|
}
|
|
133
212
|
if (orgId) {
|
|
134
|
-
clauses.push(and(eq(resourceTable.visibility, "org"), eq(resourceTable.orgId, orgId)));
|
|
213
|
+
clauses.push(and(eq(resourceTable.visibility, "org"), eq(resourceTable.orgId, orgId), orgMembership));
|
|
135
214
|
}
|
|
136
215
|
}
|
|
137
216
|
if (normalizedUserEmail) {
|
|
@@ -149,6 +228,7 @@ export function accessFilter(resourceTable, sharesTable, rawCtx = currentAccess(
|
|
|
149
228
|
where ${sharesTable.resourceId} = ${resourceTable.id}
|
|
150
229
|
and ${sharesTable.principalType} = 'org'
|
|
151
230
|
and ${sharesTable.principalId} = ${orgId}
|
|
231
|
+
and ${orgMembership}
|
|
152
232
|
and ${shareScope}
|
|
153
233
|
and ${minRoleSql(minRole)})`);
|
|
154
234
|
}
|
|
@@ -160,8 +240,8 @@ export function accessFilter(resourceTable, sharesTable, rawCtx = currentAccess(
|
|
|
160
240
|
select 1 from organizations as federation_org
|
|
161
241
|
where federation_org.id = ${resourceTable.orgId}
|
|
162
242
|
and (
|
|
163
|
-
federation_org.identity_authority
|
|
164
|
-
or federation_org.identity_id
|
|
243
|
+
coalesce(trim(federation_org.identity_authority), '') <> ''
|
|
244
|
+
or coalesce(trim(federation_org.identity_id), '') <> ''
|
|
165
245
|
)
|
|
166
246
|
)`;
|
|
167
247
|
const groupMemberPredicate = sql `exists (
|
|
@@ -180,6 +260,7 @@ export function accessFilter(resourceTable, sharesTable, rawCtx = currentAccess(
|
|
|
180
260
|
where workspace_group.id = ${sharesTable.principalId}
|
|
181
261
|
and workspace_group.org_id = ${resourceTable.orgId}
|
|
182
262
|
and workspace_group.org_id = ${orgId}
|
|
263
|
+
and ${orgMembership}
|
|
183
264
|
and ${federationGuard}
|
|
184
265
|
and exists (
|
|
185
266
|
select 1 from ${orgMembers} as workspace_member
|
package/dist/triggers/routes.js
CHANGED
|
@@ -8,6 +8,7 @@ import { insertPendingTask, isDuplicateEventError, } from "../integrations/pendi
|
|
|
8
8
|
import { RESUME_AUTOMATION_PATCH } from "../jobs/automation-outcome.js";
|
|
9
9
|
import { nextOccurrence, describeCron, effectiveTimezone, isValidCron, } from "../jobs/cron.js";
|
|
10
10
|
import { parseJobResource, patchJobFrontmatterFields, } from "../jobs/frontmatter.js";
|
|
11
|
+
import { automationRunOwnership } from "../jobs/run-history-ownership.js";
|
|
11
12
|
import { getOrgContext } from "../org/context.js";
|
|
12
13
|
import { organizationIdFromResourceOwner, organizationResourceOwner, resourceGetByPath, resourceListAllOwners, resourcePut, SHARED_OWNER, } from "../resources/store.js";
|
|
13
14
|
import { getSession } from "../server/auth.js";
|
|
@@ -135,7 +136,7 @@ async function resourceToAutomationItem(event, userEmail, resource) {
|
|
|
135
136
|
owner: resource.owner,
|
|
136
137
|
appId: meta.appId,
|
|
137
138
|
orgId: meta.orgId,
|
|
138
|
-
scope: resource.owner
|
|
139
|
+
scope: automationRunOwnership(resource.owner, userEmail, meta.orgId).scope,
|
|
139
140
|
canUpdate,
|
|
140
141
|
triggerType: meta.triggerType,
|
|
141
142
|
event: meta.event,
|
|
@@ -22,9 +22,9 @@ An app assigns each workflow a stable ID, a connector ID, input/output schemas,
|
|
|
22
22
|
|
|
23
23
|
## Worked example: notify Slack via n8n {#worked-example}
|
|
24
24
|
|
|
25
|
-
An app that wants to hand a "notify sales" job to an n8n workflow defines it once, then invokes it by ID — never by URL:
|
|
25
|
+
An app that wants to hand a "notify sales" job to an n8n workflow defines it once, then invokes it by ID — never by URL. No first-party template configures a workflow connector yet, so an app opts in by adding an action like this one:
|
|
26
26
|
|
|
27
|
-
```ts filename="
|
|
27
|
+
```ts filename="actions/invoke-automation-workflow.ts"
|
|
28
28
|
import {
|
|
29
29
|
createAutomationRuntime,
|
|
30
30
|
createInvokeAutomationWorkflowAction,
|
|
@@ -57,7 +57,7 @@ const automation = createAutomationRuntime({
|
|
|
57
57
|
baseUrl: "https://n8n.example.com",
|
|
58
58
|
path: "/webhook/notify-sales",
|
|
59
59
|
method: "POST",
|
|
60
|
-
headers: { Authorization: "Bearer ${keys.
|
|
60
|
+
headers: { Authorization: "Bearer ${keys.N8N_WEBHOOK_CREDENTIAL}" },
|
|
61
61
|
timeoutMs: 10_000,
|
|
62
62
|
},
|
|
63
63
|
},
|
|
@@ -80,6 +80,8 @@ export default createInvokeAutomationWorkflowAction(automation);
|
|
|
80
80
|
|
|
81
81
|
The agent calls this action with `{ workflowId: "notify-sales-slack", input: { leadEmail: "jane@acme.com" } }` — it never sees `https://n8n.example.com/webhook/notify-sales` or the bearer token. `outbound.baseUrl` + `outbound.path` are static, app-owned config; the runtime rejects any attempt to call a different origin.
|
|
82
82
|
|
|
83
|
+
`N8N_WEBHOOK_CREDENTIAL` is the credential name the n8n integration lists. Save the credential as a key with that name, and `${keys.N8N_WEBHOOK_CREDENTIAL}` resolves it server-side. The file name sets the action name, so the agent sees this tool as `invoke-automation-workflow`.
|
|
84
|
+
|
|
83
85
|
Mount callback handlers only under `/_agent-native/automations/`; all normal app operations remain actions. Keep the callback route minimal: authenticate, claim idempotency, enqueue, and acknowledge — never run an agent loop inside the webhook request itself.
|
|
84
86
|
|
|
85
87
|
The client calls a registered action with `invokeConfiguredAutomationWorkflow()` from `@agent-native/core/client`; it does not hand-write a browser `fetch`.
|
|
@@ -5,11 +5,12 @@ description: "Event-triggered and scheduled automations with natural-language co
|
|
|
5
5
|
|
|
6
6
|
# Automations
|
|
7
7
|
|
|
8
|
-
An **automation** is an agent-executed task that fires on a schedule
|
|
9
|
-
event occurs: _when X happens, do Y_.
|
|
10
|
-
trigger types, not separate
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
An **automation** is an agent-executed task that fires on a schedule, when an
|
|
9
|
+
event occurs, or when a webhook is called: _when X happens, do Y_.
|
|
10
|
+
**Scheduled**, **Event**, and **Webhook** are trigger types, not separate
|
|
11
|
+
products. The agent executes the instructions with the same action and tool
|
|
12
|
+
lifecycle as interactive chat; connected MCP access is limited to the
|
|
13
|
+
automation's explicit `mcpTools` allowlist.
|
|
13
14
|
|
|
14
15
|
The framework originally exposed scheduled automations as **Recurring Jobs**.
|
|
15
16
|
That name, the `jobs/<name>.md` storage path, and the `manage-jobs` tool remain
|
|
@@ -29,7 +30,7 @@ schedule-only integration or working with scheduler internals.
|
|
|
29
30
|
<div class="diagram-card">
|
|
30
31
|
<span class="diagram-pill">Condition</span
|
|
31
32
|
><small class="diagram-muted"
|
|
32
|
-
>
|
|
33
|
+
>Model checks: “email ends with @builder.io”</small
|
|
33
34
|
>
|
|
34
35
|
</div>
|
|
35
36
|
<div class="diagram-arrow diagram-muted" aria-hidden="true">→</div>
|
|
@@ -63,14 +64,32 @@ schedule-only integration or working with scheduler internals.
|
|
|
63
64
|
|
|
64
65
|
</Diagram>
|
|
65
66
|
|
|
66
|
-
##
|
|
67
|
+
## Trigger types {#trigger-types}
|
|
67
68
|
|
|
68
|
-
| Type | Fires when
|
|
69
|
-
| ---------- |
|
|
70
|
-
| `schedule` | A cron expression matches (same as recurring jobs)
|
|
71
|
-
| `event` | A matching event is emitted on the framework event bus
|
|
69
|
+
| Type | Fires when | Key field |
|
|
70
|
+
| ---------- | ------------------------------------------------------------- | ---------------------------------- |
|
|
71
|
+
| `schedule` | A cron expression matches (same as recurring jobs) | `schedule` (cron) |
|
|
72
|
+
| `event` | A matching event is emitted on the framework event bus | `event` (name) |
|
|
73
|
+
| `webhook` | An HTTP `POST` arrives at the automation's secret webhook URL | The URL returned when it's created |
|
|
72
74
|
|
|
73
|
-
Event triggers can include a `condition
|
|
75
|
+
Event and webhook triggers can include a `condition`: a natural-language string a model checks against the payload before dispatch. If the condition doesn't match, the automation is skipped and records a `skipped` status. If the condition can't be evaluated, the automation doesn't run. See [Conditions](#conditions).
|
|
76
|
+
|
|
77
|
+
### Webhook triggers {#webhook-triggers}
|
|
78
|
+
|
|
79
|
+
Creating a webhook automation returns a URL path of the form
|
|
80
|
+
`/_agent-native/automations/webhook/<token>`. The token is the secret, so give
|
|
81
|
+
the URL only to the service that calls it. Each `POST` to that URL becomes the
|
|
82
|
+
automation's payload, up to 64 KB. A body sent with a JSON content type is
|
|
83
|
+
parsed, and any other body is passed as text. The endpoint answers `202` and
|
|
84
|
+
runs the automation in the background.
|
|
85
|
+
|
|
86
|
+
Send a stable event ID in an `X-Webhook-Event-Id`, `X-Event-Id`, or
|
|
87
|
+
`X-GitHub-Delivery` header, or as a top-level string `id` in a JSON body. A
|
|
88
|
+
repeated delivery with the same ID isn't queued again and gets a `200` reply
|
|
89
|
+
marked as a duplicate. The ID only stops duplicate deliveries. A failed run can
|
|
90
|
+
be retried, and the retry can repeat side effects the failed run already
|
|
91
|
+
caused. Once a delivery fails for good, its ID is released, and a later
|
|
92
|
+
delivery with that ID is queued as a new one.
|
|
74
93
|
|
|
75
94
|
## Creating automations {#creating}
|
|
76
95
|
|
|
@@ -82,10 +101,17 @@ The agent discovers available events, confirms the plan, and writes the automati
|
|
|
82
101
|
|
|
83
102
|
### From the Automations UI
|
|
84
103
|
|
|
85
|
-
Open **
|
|
86
|
-
|
|
87
|
-
them, and delete them.
|
|
88
|
-
|
|
104
|
+
Open **Settings › Automations**. It is the primary place to view scheduled,
|
|
105
|
+
event-triggered, and webhook-triggered automations, inspect status, run one
|
|
106
|
+
now, pause or resume them, and delete them. Old `/agent#jobs` links redirect
|
|
107
|
+
there.
|
|
108
|
+
|
|
109
|
+
**Run now** is the quickest way to test an automation. It runs the
|
|
110
|
+
automation's instructions immediately, with their real side effects. It skips
|
|
111
|
+
the trigger and the condition, and it doesn't change the next scheduled run,
|
|
112
|
+
with one exception: if the framework paused the automation, a successful run
|
|
113
|
+
turns it back on, and a scheduled automation gets its next run calculated
|
|
114
|
+
again. The agent can do the same with `manage-automations` and `action=run-now`.
|
|
89
115
|
|
|
90
116
|
<Callout tone="info">
|
|
91
117
|
|
|
@@ -120,7 +146,7 @@ The third path — writing the `jobs/<name>.md` file by hand via `resourcePut`
|
|
|
120
146
|
{
|
|
121
147
|
lines: "6",
|
|
122
148
|
label: "Gate",
|
|
123
|
-
note: "An optional natural-language `condition`,
|
|
149
|
+
note: "An optional natural-language `condition`, checked by a model against the payload before dispatch.",
|
|
124
150
|
},
|
|
125
151
|
{
|
|
126
152
|
lines: "12",
|
|
@@ -134,15 +160,15 @@ The third path — writing the `jobs/<name>.md` file by hand via `resourcePut`
|
|
|
134
160
|
|
|
135
161
|
Automations share every field in the [recurring-jobs frontmatter table](/docs/recurring-jobs#frontmatter). These additional fields control event triggers, conditions, and the execution mode:
|
|
136
162
|
|
|
137
|
-
| Field | Type
|
|
138
|
-
| ------------- |
|
|
139
|
-
| `triggerType` | `"schedule"` \| `"event"` | `"schedule"` | How the automation fires |
|
|
140
|
-
| `event` | string
|
|
141
|
-
| `condition` | string
|
|
142
|
-
| `mode` | `"agentic"`
|
|
143
|
-
| `domain` | string
|
|
163
|
+
| Field | Type | Default | Description |
|
|
164
|
+
| ------------- | ---------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
165
|
+
| `triggerType` | `"schedule"` \| `"event"` \| `"webhook"` | `"schedule"` | How the automation fires |
|
|
166
|
+
| `event` | string | _(optional)_ | Event name to subscribe to (event triggers only) |
|
|
167
|
+
| `condition` | string | _(optional)_ | Natural-language condition evaluated before dispatch (event and webhook triggers) |
|
|
168
|
+
| `mode` | `"agentic"` | `"agentic"` | Full agent loop (the only supported mode). `"deterministic"` was removed — it was never implemented, and automations that set it never fired. |
|
|
169
|
+
| `domain` | string | _(optional)_ | Grouping tag (mail, calendar, clips, etc.) |
|
|
144
170
|
|
|
145
|
-
For an event trigger, `schedule` is `""` (empty); for a schedule trigger it carries the cron expression. The dispatcher also writes the same managed `lastRun` / `lastStatus` / `lastError` fields the scheduler does, plus a `"skipped"` status when a condition evaluates to false.
|
|
171
|
+
For an event or webhook trigger, `schedule` is `""` (empty); for a schedule trigger it carries the cron expression. The dispatcher also writes the same managed `lastRun` / `lastStatus` / `lastError` fields the scheduler does, plus a `"skipped"` status when a condition evaluates to false. When a condition can't be evaluated, see [Conditions](#conditions) for what gets recorded.
|
|
146
172
|
|
|
147
173
|
`model` selects a per-automation model. `mcpTools` is an explicit list of MCP
|
|
148
174
|
tool names such as `mcp__server__tool`. The shared execution runner exposes
|
|
@@ -224,15 +250,15 @@ organization event automations both require an exact match with their
|
|
|
224
250
|
|
|
225
251
|
## Conditions {#conditions}
|
|
226
252
|
|
|
227
|
-
Conditions are natural-language strings
|
|
253
|
+
Conditions are natural-language strings a model checks against the event or webhook payload. This is a yes/no classification, not a generation task.
|
|
228
254
|
|
|
229
255
|
- **Empty or missing condition** = unconditional (always fires).
|
|
230
|
-
- Results are
|
|
231
|
-
- Payload is truncated to 4000 characters before
|
|
256
|
+
- Results are cached for 5 minutes, keyed by a SHA-256 hash of the condition and payload, with at most 500 entries.
|
|
257
|
+
- Payload is truncated to 4000 characters before it is sent to the model.
|
|
232
258
|
|
|
233
259
|
<Callout tone="info">
|
|
234
260
|
|
|
235
|
-
|
|
261
|
+
A condition that can't be evaluated never counts as a match or as a non-match, and the automation doesn't run. A provider error records `lastStatus: error` with the reason in `lastError`, and the event is retried. If no API key is available to evaluate the condition, an event automation records an error for each matching event and pauses itself after three in a row. Once a key is available, a running scheduler turns it back on by itself within about 15 minutes. On a host where no scheduler runs, turn it back on in **Settings › Automations**. A webhook automation in the same state doesn't pause. Its delivery fails and is retried.
|
|
236
262
|
|
|
237
263
|
</Callout>
|
|
238
264
|
|
|
@@ -283,7 +309,7 @@ Placeholders are resolved **server-side** after the agent emits the tool call
|
|
|
283
309
|
Keys are ad-hoc secrets created by users or the agent for automation use (e.g. `SLACK_WEBHOOK`, `HUBSPOT_API_KEY`). They differ from registered secrets (`registerRequiredSecret`) in that they have no template-defined metadata or onboarding step.
|
|
284
310
|
|
|
285
311
|
- Created via the settings UI or the `/_agent-native/secrets/adhoc` API.
|
|
286
|
-
-
|
|
312
|
+
- A key created through the API can include a `urlAllowlist` that restricts which origins the key can be sent to (origin-level matching). Keys added in Settings have no allowlist.
|
|
287
313
|
- The raw value is never exposed to the AI — only `${keys.NAME}` placeholders appear in the agent's context.
|
|
288
314
|
- Resolution falls back from user scope to workspace scope, so users can override shared keys.
|
|
289
315
|
|
|
@@ -292,14 +318,16 @@ Keys are ad-hoc secrets created by users or the agent for automation use (e.g. `
|
|
|
292
318
|
Trigger-aware personal and organization operations use `manage-automations`
|
|
293
319
|
with an `action` parameter:
|
|
294
320
|
|
|
295
|
-
| Action | Purpose
|
|
296
|
-
| ------------- |
|
|
297
|
-
| `list-events` | Discover all registered events with descriptions and payload schemas
|
|
298
|
-
| `list`
|
|
299
|
-
| `
|
|
300
|
-
| `
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
321
|
+
| Action | Purpose |
|
|
322
|
+
| ------------- | ---------------------------------------------------------------------------------------- |
|
|
323
|
+
| `list-events` | Discover all registered events with descriptions and payload schemas |
|
|
324
|
+
| `list-hosts` | List paired execution hosts before assigning one to an automation |
|
|
325
|
+
| `list` | List all automations with status; filter by domain or enabled |
|
|
326
|
+
| `define` | Create a new automation (name, trigger type, event, condition, body) |
|
|
327
|
+
| `update` | Update an existing automation (enabled, condition, body) |
|
|
328
|
+
| `delete` | Delete an automation (always confirms with user first) |
|
|
329
|
+
| `run-now` | Run one automation immediately, with real side effects. Skips the trigger and condition. |
|
|
330
|
+
| `fire-test` | Emit a `test.event.fired` event. Only automations subscribed to that event respond. |
|
|
303
331
|
|
|
304
332
|
Additional tool: `web-request` — outbound HTTP with `${keys.NAME}` substitution.
|
|
305
333
|
|
|
@@ -309,22 +337,23 @@ tool for existing schedule-only integrations and delivery metadata.
|
|
|
309
337
|
|
|
310
338
|
## API endpoints {#api}
|
|
311
339
|
|
|
312
|
-
| Endpoint
|
|
313
|
-
|
|
|
314
|
-
| `/_agent-native/automations`
|
|
315
|
-
| `/_agent-native/automations/fire-test`
|
|
316
|
-
| `/_agent-native/
|
|
317
|
-
| `/_agent-native/secrets/adhoc`
|
|
318
|
-
| `/_agent-native/secrets/adhoc
|
|
340
|
+
| Endpoint | Method | Description |
|
|
341
|
+
| ------------------------------------------- | ------ | ------------------------------- |
|
|
342
|
+
| `/_agent-native/automations` | GET | List all automations (parsed) |
|
|
343
|
+
| `/_agent-native/automations/fire-test` | POST | Emit a `test.event.fired` event |
|
|
344
|
+
| `/_agent-native/automations/webhook/:token` | POST | Trigger a webhook automation |
|
|
345
|
+
| `/_agent-native/secrets/adhoc` | GET | List ad-hoc keys (no values) |
|
|
346
|
+
| `/_agent-native/secrets/adhoc` | POST | Create or update an ad-hoc key |
|
|
347
|
+
| `/_agent-native/secrets/adhoc/:name` | DELETE | Delete an ad-hoc key |
|
|
319
348
|
|
|
320
|
-
<Endpoint id="doc-block-uyjlfp" title="Fire a test event" method="POST" path="/_agent-native/automations/fire-test" summary="Emit a test.event.fired event to
|
|
349
|
+
<Endpoint id="doc-block-uyjlfp" title="Fire a test event" method="POST" path="/_agent-native/automations/fire-test" summary="Emit a test.event.fired event to exercise automations subscribed to it" responses={[
|
|
321
350
|
{
|
|
322
351
|
"status": "200",
|
|
323
352
|
"description": "Event emitted; matching automations are dispatched through the normal condition + ownership path."
|
|
324
353
|
}
|
|
325
354
|
]}>
|
|
326
355
|
|
|
327
|
-
|
|
356
|
+
Checks the event path (matching, owner scoping, and condition) for an automation subscribed to `test.event.fired`. It emits only that event, so it can't trigger an automation that listens for a real event such as `calendar.booking.created`. To try a real automation, use **Run now**. Equivalent to the `manage-automations` action `fire-test`.
|
|
328
357
|
|
|
329
358
|
</Endpoint>
|
|
330
359
|
|
|
@@ -354,8 +383,8 @@ Confirm an automation's wiring and condition without waiting for a real provider
|
|
|
354
383
|
<div class="diagram-card">
|
|
355
384
|
<span class="diagram-pill warn">condition</span
|
|
356
385
|
><small class="diagram-muted"
|
|
357
|
-
>
|
|
358
|
-
<code>
|
|
386
|
+
>model yes/no on the payload — false → <code>skipped</code>,
|
|
387
|
+
failure → <code>error</code></small
|
|
359
388
|
>
|
|
360
389
|
</div>
|
|
361
390
|
<div class="diagram-arrow diagram-muted" aria-hidden="true">↓</div>
|